lichen-client-sdk 0.1.1

Lichen Rust SDK - Build on Lichen with Rust
Documentation
use crate::client::ReadonlyContractResult;
use crate::{Client, Error, Keypair, Pubkey, Result};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};

const PROGRAM_SYMBOL_CANDIDATES: [&str; 5] = ["SPOREVAULT", "sporevault", "SporeVault", "VAULT", "vault"];
const VAULT_STATS_SIZE: usize = 48;
const USER_POSITION_SIZE: usize = 16;
const STRATEGY_INFO_SIZE: usize = 24;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SporeVaultVaultStats {
    pub total_assets: u64,
    pub total_shares: u64,
    pub share_price_e9: u64,
    pub strategy_count: u64,
    pub total_earned: u64,
    pub fees_earned: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SporeVaultUserPosition {
    pub shares: u64,
    pub estimated_value: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SporeVaultStrategyInfo {
    pub strategy_type: u64,
    pub allocation_percent: u64,
    pub deployed_amount: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SporeVaultStats {
    pub total_assets: u64,
    pub total_shares: u64,
    pub strategy_count: u64,
    pub total_earned: u64,
    pub fees_earned: u64,
    pub protocol_fees: u64,
    pub paused: bool,
}

#[derive(Debug, Clone)]
pub struct SporeVaultClient {
    client: Client,
    program_id: Arc<Mutex<Option<Pubkey>>>,
}

fn build_layout_args(layout: &[u8], chunks: &[Vec<u8>]) -> Vec<u8> {
    let mut out = Vec::with_capacity(1 + layout.len() + chunks.iter().map(|chunk| chunk.len()).sum::<usize>());
    out.push(0xAB);
    out.extend_from_slice(layout);
    for chunk in chunks {
        out.extend_from_slice(chunk);
    }
    out
}

fn encode_user_amount_args(user: &Pubkey, amount: u64) -> Vec<u8> {
    build_layout_args(&[0x20, 0x08], &[user.as_ref().to_vec(), amount.to_le_bytes().to_vec()])
}

fn encode_user_lookup_args(user: &Pubkey) -> Vec<u8> {
    build_layout_args(&[0x20], &[user.as_ref().to_vec()])
}

fn encode_index_args(index: u64) -> Vec<u8> {
    build_layout_args(&[0x08], &[index.to_le_bytes().to_vec()])
}

fn ensure_readonly_success(
    result: &ReadonlyContractResult,
    function_name: &str,
    allowed_codes: &[u32],
) -> Result<()> {
    let code = result.return_code.unwrap_or(0);
    if !allowed_codes.contains(&code) {
        return Err(Error::RpcError(
            result
                .error
                .clone()
                .unwrap_or_else(|| format!("SporeVault {} returned code {}", function_name, code)),
        ));
    }
    if !result.success {
        return Err(Error::RpcError(
            result
                .error
                .clone()
                .unwrap_or_else(|| format!("SporeVault {} failed", function_name)),
        ));
    }
    Ok(())
}

fn decode_return_data(result: &ReadonlyContractResult, function_name: &str) -> Result<Vec<u8>> {
    let Some(return_data) = &result.return_data else {
        return Err(Error::ParseError(format!(
            "SporeVault {} did not return payload data",
            function_name,
        )));
    };

    base64::Engine::decode(&base64::engine::general_purpose::STANDARD, return_data)
        .map_err(|err| Error::ParseError(err.to_string()))
}

fn decode_u64(bytes: &[u8], start: usize, function_name: &str) -> Result<u64> {
    let end = start + 8;
    if bytes.len() < end {
        return Err(Error::ParseError(format!(
            "SporeVault {} payload was shorter than expected",
            function_name,
        )));
    }
    let slice: [u8; 8] = bytes[start..end]
        .try_into()
        .map_err(|_| Error::ParseError(format!("SporeVault {} payload was malformed", function_name)))?;
    Ok(u64::from_le_bytes(slice))
}

fn decode_vault_stats(result: &ReadonlyContractResult) -> Result<SporeVaultVaultStats> {
    ensure_readonly_success(result, "get_vault_stats", &[0])?;
    let bytes = decode_return_data(result, "get_vault_stats")?;
    if bytes.len() < VAULT_STATS_SIZE {
        return Err(Error::ParseError(
            "SporeVault get_vault_stats payload was shorter than expected".into(),
        ));
    }

    Ok(SporeVaultVaultStats {
        total_assets: decode_u64(&bytes, 0, "get_vault_stats")?,
        total_shares: decode_u64(&bytes, 8, "get_vault_stats")?,
        share_price_e9: decode_u64(&bytes, 16, "get_vault_stats")?,
        strategy_count: decode_u64(&bytes, 24, "get_vault_stats")?,
        total_earned: decode_u64(&bytes, 32, "get_vault_stats")?,
        fees_earned: decode_u64(&bytes, 40, "get_vault_stats")?,
    })
}

fn decode_user_position(result: &ReadonlyContractResult) -> Result<SporeVaultUserPosition> {
    ensure_readonly_success(result, "get_user_position", &[0])?;
    let bytes = decode_return_data(result, "get_user_position")?;
    if bytes.len() < USER_POSITION_SIZE {
        return Err(Error::ParseError(
            "SporeVault get_user_position payload was shorter than expected".into(),
        ));
    }

    Ok(SporeVaultUserPosition {
        shares: decode_u64(&bytes, 0, "get_user_position")?,
        estimated_value: decode_u64(&bytes, 8, "get_user_position")?,
    })
}

fn decode_strategy_info(result: &ReadonlyContractResult) -> Result<SporeVaultStrategyInfo> {
    ensure_readonly_success(result, "get_strategy_info", &[0])?;
    let bytes = decode_return_data(result, "get_strategy_info")?;
    if bytes.len() < STRATEGY_INFO_SIZE {
        return Err(Error::ParseError(
            "SporeVault get_strategy_info payload was shorter than expected".into(),
        ));
    }

    Ok(SporeVaultStrategyInfo {
        strategy_type: decode_u64(&bytes, 0, "get_strategy_info")?,
        allocation_percent: decode_u64(&bytes, 8, "get_strategy_info")?,
        deployed_amount: decode_u64(&bytes, 16, "get_strategy_info")?,
    })
}

impl SporeVaultClient {
    pub fn new(client: Client) -> Self {
        Self {
            client,
            program_id: Arc::new(Mutex::new(None)),
        }
    }

    pub fn with_program_id(client: Client, program_id: Pubkey) -> Self {
        Self {
            client,
            program_id: Arc::new(Mutex::new(Some(program_id))),
        }
    }

    pub async fn get_program_id(&self) -> Result<Pubkey> {
        if let Some(program_id) = self
            .program_id
            .lock()
            .map_err(|_| Error::ConfigError("SporeVaultClient program cache lock poisoned".into()))?
            .clone()
        {
            return Ok(program_id);
        }

        for symbol in PROGRAM_SYMBOL_CANDIDATES {
            let entry = match self.client.get_symbol_registry(symbol).await {
                Ok(entry) => entry,
                Err(_) => continue,
            };
            let Some(program) = entry.get("program").and_then(|value| value.as_str()) else {
                continue;
            };
            let program_id = Pubkey::from_base58(program).map_err(Error::ParseError)?;
            *self
                .program_id
                .lock()
                .map_err(|_| Error::ConfigError("SporeVaultClient program cache lock poisoned".into()))? = Some(program_id);
            return Ok(program_id);
        }

        Err(Error::ConfigError(
            "Unable to resolve the SporeVault program via getSymbolRegistry(\"SPOREVAULT\")".into(),
        ))
    }

    pub async fn get_vault_stats(&self) -> Result<SporeVaultVaultStats> {
        let result = self
            .client
            .call_readonly_contract(&self.get_program_id().await?, "get_vault_stats", Vec::new(), None)
            .await?;
        decode_vault_stats(&result)
    }

    pub async fn get_user_position(&self, user: &Pubkey) -> Result<SporeVaultUserPosition> {
        let result = self
            .client
            .call_readonly_contract(
                &self.get_program_id().await?,
                "get_user_position",
                encode_user_lookup_args(user),
                None,
            )
            .await?;
        decode_user_position(&result)
    }

    pub async fn get_strategy_info(&self, index: u64) -> Result<Option<SporeVaultStrategyInfo>> {
        let result = self
            .client
            .call_readonly_contract(
                &self.get_program_id().await?,
                "get_strategy_info",
                encode_index_args(index),
                None,
            )
            .await?;

        if result.return_code == Some(1) || result.return_data.is_none() {
            return Ok(None);
        }

        decode_strategy_info(&result).map(Some)
    }

    pub async fn get_stats(&self) -> Result<SporeVaultStats> {
        let value = self.client.get_sporevault_stats().await?;
        serde_json::from_value(value).map_err(|err| Error::ParseError(err.to_string()))
    }

    pub async fn deposit(&self, depositor: &Keypair, amount: u64) -> Result<String> {
        let program_id = self.get_program_id().await?;
        self.client
            .call_contract(
                depositor,
                &program_id,
                "deposit",
                encode_user_amount_args(&depositor.pubkey(), amount),
                amount,
            )
            .await
    }

    pub async fn withdraw(&self, depositor: &Keypair, shares_to_burn: u64) -> Result<String> {
        let program_id = self.get_program_id().await?;
        self.client
            .call_contract(
                depositor,
                &program_id,
                "withdraw",
                encode_user_amount_args(&depositor.pubkey(), shares_to_burn),
                0,
            )
            .await
    }

    pub async fn harvest(&self, caller: &Keypair) -> Result<String> {
        let program_id = self.get_program_id().await?;
        self.client
            .call_contract(caller, &program_id, "harvest", Vec::new(), 0)
            .await
    }
}

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

    fn readonly_result(return_code: u32, bytes: Vec<u8>) -> ReadonlyContractResult {
        ReadonlyContractResult {
            success: true,
            return_data: Some(base64::Engine::encode(
                &base64::engine::general_purpose::STANDARD,
                bytes,
            )),
            return_code: Some(return_code),
            logs: Vec::new(),
            error: None,
            compute_used: None,
        }
    }

    #[test]
    fn user_amount_encoding_matches_named_export_layout() {
        let user = Pubkey([7u8; 32]);
        let encoded = encode_user_amount_args(&user, 1_000);

        assert_eq!(&encoded[..3], &[0xAB, 0x20, 0x08]);
        assert_eq!(&encoded[3..35], &[7u8; 32]);
        assert_eq!(u64::from_le_bytes(encoded[35..43].try_into().unwrap()), 1_000);
    }

    #[test]
    fn index_encoding_matches_named_export_layout() {
        let encoded = encode_index_args(3);

        assert_eq!(&encoded[..2], &[0xAB, 0x08]);
        assert_eq!(u64::from_le_bytes(encoded[2..10].try_into().unwrap()), 3);
    }

    #[test]
    fn vault_stats_and_user_position_decoding_match_contract_layouts() {
        let vault_result = readonly_result(
            0,
            [
                5_000u64.to_le_bytes().as_slice(),
                4_500u64.to_le_bytes().as_slice(),
                1_111_111_111u64.to_le_bytes().as_slice(),
                2u64.to_le_bytes().as_slice(),
                900u64.to_le_bytes().as_slice(),
                100u64.to_le_bytes().as_slice(),
            ]
            .concat(),
        );

        let user_result = readonly_result(
            0,
            [200u64.to_le_bytes().as_slice(), 222u64.to_le_bytes().as_slice()].concat(),
        );

        let vault_stats = decode_vault_stats(&vault_result).unwrap();
        let user_position = decode_user_position(&user_result).unwrap();

        assert_eq!(
            vault_stats,
            SporeVaultVaultStats {
                total_assets: 5_000,
                total_shares: 4_500,
                share_price_e9: 1_111_111_111,
                strategy_count: 2,
                total_earned: 900,
                fees_earned: 100,
            }
        );
        assert_eq!(
            user_position,
            SporeVaultUserPosition {
                shares: 200,
                estimated_value: 222,
            }
        );
    }

    #[test]
    fn strategy_info_decoding_matches_contract_layout() {
        let result = readonly_result(
            0,
            [1u64.to_le_bytes().as_slice(), 60u64.to_le_bytes().as_slice(), 3_000u64.to_le_bytes().as_slice()].concat(),
        );

        let strategy = decode_strategy_info(&result).unwrap();

        assert_eq!(
            strategy,
            SporeVaultStrategyInfo {
                strategy_type: 1,
                allocation_percent: 60,
                deployed_amount: 3_000,
            }
        );
    }
}