lumera-sdk-rs 0.1.0

Rust SDK for Lumera chain + Cascade/sn-api workflows
use std::path::Path;

use base64::{engine::general_purpose::STANDARD, Engine as _};
use rand::Rng;
use serde::{Deserialize, Serialize};

use crate::{
    chain::{ChainClient, RequestActionTxInput},
    error::SdkError,
    snapi::SnApiClient,
};

#[derive(Debug, Clone)]
pub struct CascadeConfig {
    pub chain: crate::chain::ChainConfig,
    pub snapi_base: String,
}

impl CascadeConfig {
    pub fn new(chain: crate::chain::ChainConfig, snapi_base: impl Into<String>) -> Self {
        Self {
            chain,
            snapi_base: snapi_base.into(),
        }
    }

    pub fn with_snapi_base(mut self, snapi_base: impl Into<String>) -> Self {
        self.snapi_base = snapi_base.into();
        self
    }
}

#[derive(Debug, Clone)]
pub struct RegisterTicketRequest {
    pub file_name: String,
    pub is_public: bool,
    pub expiration_time: String,
}

#[derive(Debug, Clone)]
pub struct RegisteredTicket {
    pub action_id: String,
    pub auth_signature: String,
    pub data_hash_b64: String,
    pub metadata_json: String,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct IndexFile {
    pub layout_ids: Vec<String>,
    pub layout_signature: String,
    pub version: i32,
}

pub struct CascadeSdk {
    pub chain: ChainClient,
    pub snapi: SnApiClient,
}

impl CascadeSdk {
    pub fn new(cfg: CascadeConfig) -> Self {
        Self {
            chain: ChainClient::new(cfg.chain),
            snapi: SnApiClient::new(cfg.snapi_base),
        }
    }

    pub fn compute_data_hash_b64(file_bytes: &[u8]) -> String {
        let h = blake3::hash(file_bytes);
        STANDARD.encode(h.as_bytes())
    }

    pub fn create_layout_b64(file_path: &Path) -> Result<String, SdkError> {
        #[derive(serde::Deserialize, serde::Serialize)]
        struct GoLayout {
            blocks: Vec<GoBlock>,
        }
        #[derive(serde::Deserialize, serde::Serialize)]
        struct GoBlock {
            block_id: i32,
            encoder_parameters: Vec<u8>,
            original_offset: i64,
            size: i64,
            symbols: Vec<String>,
            hash: String,
        }

        let cfg = rq_library::ProcessorConfig {
            symbol_size: 65535,
            redundancy_factor: 6,
            max_memory_mb: 8192,
            concurrency_limit: 1,
        };
        let processor = rq_library::RaptorQProcessor::new(cfg);

        // Match supernode codec defaults (single-block deterministic metadata)
        let block_size: usize = 1280 * 1024 * 1024;
        let result = processor
            .create_metadata(
                file_path
                    .to_str()
                    .ok_or_else(|| SdkError::InvalidInput("non-utf8 file path".into()))?,
                "",
                block_size,
            )
            .map_err(|e| SdkError::Serialization(format!("rq create_metadata: {e}")))?;

        let layout_raw = result
            .layout_content
            .ok_or_else(|| SdkError::Serialization("missing layout content".into()))?;

        // Re-marshal with Go codec field order to match supernode LayoutB64 bytes exactly.
        let go_layout: GoLayout = serde_json::from_str(&layout_raw)
            .map_err(|e| SdkError::Serialization(format!("parse layout json: {e}")))?;
        let go_json = serde_json::to_vec(&go_layout)
            .map_err(|e| SdkError::Serialization(format!("marshal layout json: {e}")))?;

        Ok(STANDARD.encode(go_json))
    }

    pub fn generate_ids(base: &str, ic: u32, max: u32) -> Result<Vec<String>, SdkError> {
        let mut out = Vec::with_capacity(max as usize);
        for i in 0..max {
            let payload = format!("{}.{}", base, ic + i);
            // Supernode/chain ID derivation uses one-shot zstd level-3 compression
            // before blake3+base58; use bulk mode for byte-compatibility.
            let compressed = zstd::bulk::compress(payload.as_bytes(), 3)
                .map_err(|e| SdkError::Serialization(e.to_string()))?;
            let hash = blake3::hash(&compressed);
            out.push(bs58::encode(hash.as_bytes()).into_string());
        }
        Ok(out)
    }

    pub fn build_index_file(layout_ids: Vec<String>, layout_signature: String) -> IndexFile {
        IndexFile {
            layout_ids,
            layout_signature,
            version: 1,
        }
    }

    pub fn canonical_json_bytes<T: Serialize>(v: &T) -> Result<Vec<u8>, SdkError> {
        serde_json::to_vec(v).map_err(|e| SdkError::Serialization(e.to_string()))
    }

    pub fn random_ic(max: u32) -> u32 {
        rand::thread_rng().gen_range(0..max)
    }

    pub async fn register_ticket(
        &self,
        chain_signing_key: &cosmrs::crypto::secp256k1::SigningKey,
        arbitrary_signing_key: &k256::ecdsa::SigningKey,
        creator_addr: &str,
        file_path: &Path,
        req: RegisterTicketRequest,
    ) -> Result<RegisteredTicket, SdkError> {
        let params = self.chain.get_action_params().await?;
        let max = params.max_raptor_q_symbols;
        let ic = Self::random_ic(max);

        let file_bytes = tokio::fs::read(file_path)
            .await
            .map_err(|e| SdkError::InvalidInput(format!("read file: {e}")))?;

        let data_hash = Self::compute_data_hash_b64(&file_bytes);
        let layout_b64 = Self::create_layout_b64(file_path)?;

        let layout_sig = crate::crypto::sign_raw_message_b64(arbitrary_signing_key, &layout_b64);
        let layout_ids = Self::generate_ids(&format!("{}.{}", layout_b64, layout_sig), ic, max)?;

        let index = Self::build_index_file(layout_ids, layout_sig);
        let index_bytes = Self::canonical_json_bytes(&index)?;
        let index_b64 = STANDARD.encode(&index_bytes);
        let index_sig = crate::crypto::sign_raw_message_b64(arbitrary_signing_key, &index_b64);
        let signatures = format!("{}.{}", index_b64, index_sig);

        let metadata = serde_json::json!({
            "data_hash": data_hash,
            "file_name": req.file_name,
            "rq_ids_ic": ic,
            "signatures": signatures,
            "public": req.is_public
        });
        let metadata_json =
            serde_json::to_string(&metadata).map_err(|e| SdkError::Serialization(e.to_string()))?;

        let file_size_kbs = (file_bytes.len() as u64).div_ceil(1024);
        let fee_amount = self.chain.get_action_fee_amount(file_size_kbs).await?;
        let price = if fee_amount.chars().all(|c| c.is_ascii_digit()) {
            format!("{}{}", fee_amount, params.base_action_fee_denom)
        } else {
            fee_amount
        };

        let tx_out = self
            .chain
            .register_action(
                chain_signing_key,
                RequestActionTxInput {
                    creator: creator_addr.to_string(),
                    action_type: "CASCADE".into(),
                    metadata: metadata_json.clone(),
                    price,
                    expiration_time: req.expiration_time,
                    file_size_kbs: file_size_kbs.to_string(),
                    app_pubkey: vec![],
                },
            )
            .await?;

        let auth_signature = crate::crypto::sign_raw_message_b64(arbitrary_signing_key, &data_hash);

        Ok(RegisteredTicket {
            action_id: tx_out.action_id,
            auth_signature,
            data_hash_b64: data_hash,
            metadata_json,
        })
    }

    pub async fn upload_via_snapi(
        &self,
        action_id: &str,
        signature: &str,
        file_path: &Path,
    ) -> Result<String, SdkError> {
        self.snapi
            .start_cascade(action_id, signature, file_path)
            .await
    }

    pub async fn request_download(
        &self,
        action_id: &str,
        signing_key: &k256::ecdsa::SigningKey,
    ) -> Result<String, SdkError> {
        let sig = crate::crypto::sign_raw_message_b64(signing_key, action_id);
        self.snapi.request_download(action_id, &sig).await
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn tdd_data_hash_base64() {
        let h = CascadeSdk::compute_data_hash_b64(b"abc");
        assert!(!h.is_empty());
    }

    #[test]
    fn tdd_generate_ids_count() {
        let ids = CascadeSdk::generate_ids("layout.sig", 10, 5).unwrap();
        assert_eq!(ids.len(), 5);
    }

    #[test]
    fn tdd_index_shape() {
        let idx = CascadeSdk::build_index_file(vec!["a".into()], "sig".into());
        let js = String::from_utf8(CascadeSdk::canonical_json_bytes(&idx).unwrap()).unwrap();
        assert_eq!(
            js,
            "{\"layout_ids\":[\"a\"],\"layout_signature\":\"sig\",\"version\":1}"
        );
    }
}