zera-sdk 0.1.0

Rust SDK for ZERA transactions, validator APIs, and bridge workflows
Documentation
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex};

use async_trait::async_trait;
use prost::Message;
use zera_proto::zera_api::{
    BalanceResponse, BaseFeeResponse, NonceResponse, TokenFeeInfo, TokenFeeInfoResponse,
};
use zera_proto::zera_txn::{ContractFeeType, ContractFees, TransactionType};
use zera_sdk::api::{
    get_balance_with_client, get_base_fee_with_client, get_nonce_with_client,
    get_nonces_with_client, get_token_fee_info_with_client, GetTokenFeeInfoParams,
};
use zera_sdk::grpc::{TransportResponse, UnaryTransport, ValidatorApiClient};
use zera_sdk::Result;

type QueuedTransportResult = std::result::Result<TransportResponse, String>;
type ResponseQueueMap = HashMap<String, VecDeque<QueuedTransportResult>>;

#[derive(Clone, Default)]
struct MockTransport {
    responses: Arc<Mutex<ResponseQueueMap>>,
}

impl MockTransport {
    fn push_response<MessageType: Message>(&self, path: &str, message: &MessageType) {
        self.responses
            .lock()
            .expect("responses")
            .entry(path.to_string())
            .or_default()
            .push_back(Ok(grpc_ok(message)));
    }

    fn push_error(&self, path: &str, message: &str) {
        self.responses
            .lock()
            .expect("responses")
            .entry(path.to_string())
            .or_default()
            .push_back(Err(message.to_string()));
    }
}

#[async_trait]
impl UnaryTransport for MockTransport {
    async fn unary_bytes(&self, path: &str, _framed_request: Vec<u8>) -> Result<TransportResponse> {
        let mut responses = self.responses.lock().expect("responses");
        let queue = responses.get_mut(path).expect("missing path");
        match queue.pop_front().expect("missing queued response") {
            Ok(response) => Ok(response),
            Err(message) => Err(zera_sdk::ZeraError::Rpc(message)),
        }
    }
}

fn grpc_ok<MessageType: Message>(message: &MessageType) -> TransportResponse {
    let payload = message.encode_to_vec();
    let mut body = vec![0];
    body.extend_from_slice(&(payload.len() as u32).to_be_bytes());
    body.extend_from_slice(&payload);

    let trailers = b"grpc-status: 0\r\n";
    body.push(0x80);
    body.extend_from_slice(&(trailers.len() as u32).to_be_bytes());
    body.extend_from_slice(trailers);

    TransportResponse::ok(body)
}

fn test_address() -> String {
    bs58::encode([1u8, 2, 3, 4]).into_string()
}

#[tokio::test]
async fn nonce_wrapper_adds_one_and_defaults_to_one() {
    let transport = MockTransport::default();
    transport.push_response("/api/Nonce", &NonceResponse { nonce: 100 });
    transport.push_response("/api/Nonce", &NonceResponse { nonce: 0 });
    let client = ValidatorApiClient::with_transport(transport);

    let nonce = get_nonce_with_client(&test_address(), &client)
        .await
        .expect("nonce");
    let default_nonce = get_nonce_with_client(&test_address(), &client)
        .await
        .expect("default nonce");

    assert_eq!(nonce, 101);
    assert_eq!(default_nonce, 1);
}

#[tokio::test]
async fn nonce_wrapper_handles_multiple_addresses() {
    let transport = MockTransport::default();
    transport.push_response("/api/Nonce", &NonceResponse { nonce: 1 });
    transport.push_response("/api/Nonce", &NonceResponse { nonce: 2 });
    let client = ValidatorApiClient::with_transport(transport);

    let addresses = vec![test_address(), test_address()];
    let nonces = get_nonces_with_client(&addresses, &client)
        .await
        .expect("nonces");
    assert_eq!(nonces, vec![2, 3]);
}

#[tokio::test]
async fn balance_wrapper_normalizes_invalid_wallets() {
    let transport = MockTransport::default();
    transport.push_error("/api/Balance", "Invalid Wallet");
    let client = ValidatorApiClient::with_transport(transport);

    let response = get_balance_with_client(&test_address(), "$ZRA+0000", &client)
        .await
        .expect("normalized invalid wallet");
    assert_eq!(response.balance, "0");
    assert_eq!(response.denomination, "1");
    assert_eq!(response.rate, "0");
    assert_eq!(response.balance_nice, "0");
}

#[tokio::test]
async fn base_fee_wrapper_adds_human_readable_fields() {
    let transport = MockTransport::default();
    transport.push_response(
        "/api/BaseFee",
        &BaseFeeResponse {
            key_fee: "2000000000000000000".to_string(),
            byte_fee: "500000000000000000".to_string(),
            new_wallet_fee: "0".to_string(),
        },
    );
    let client = ValidatorApiClient::with_transport(transport);

    let response = get_base_fee_with_client(TransactionType::CoinType, None, &client)
        .await
        .expect("base fee");
    assert_eq!(response.key_fee_usd, "2");
    assert_eq!(response.byte_fee_usd, "0.5");
}

#[tokio::test]
async fn token_fee_info_wrapper_defaults_empty_strings() {
    let transport = MockTransport::default();
    transport.push_response(
        "/api/GetTokenFeeInfo",
        &TokenFeeInfoResponse {
            tokens: vec![TokenFeeInfo {
                contract_id: "$ZRA+0000".to_string(),
                rate: String::new(),
                authorized: true,
                denomination: String::new(),
                contract_fees: Some(ContractFees {
                    fee: String::new(),
                    burn: String::new(),
                    validator: String::new(),
                    contract_fee_type: ContractFeeType::Fixed as i32,
                    ..Default::default()
                }),
                allowed_fees: String::new(),
                used_fees: String::new(),
            }],
        },
    );
    let client = ValidatorApiClient::with_transport(transport);

    let response = get_token_fee_info_with_client(
        GetTokenFeeInfoParams {
            contract_ids: vec!["$ZRA+0000".to_string()],
        },
        &client,
    )
    .await
    .expect("token fee info");

    let token = &response.tokens[0];
    assert_eq!(token.rate, "0");
    assert_eq!(token.denomination, "1");
    assert_eq!(token.allowed_fees, "0");
    assert_eq!(token.used_fees, "0");
    let fees = token.contract_fees.as_ref().expect("contract fees");
    assert_eq!(fees.fee, "0");
    assert_eq!(fees.burn, "0");
    assert_eq!(fees.validator, "0");
}

#[tokio::test]
async fn validator_client_decodes_balance_response() {
    let transport = MockTransport::default();
    transport.push_response(
        "/api/Balance",
        &BalanceResponse {
            balance: "1000".to_string(),
            denomination: "100".to_string(),
            rate: "2000000000000000000".to_string(),
        },
    );
    let client = ValidatorApiClient::with_transport(transport);

    let response = get_balance_with_client(&test_address(), "$ZRA+0000", &client)
        .await
        .expect("balance");
    assert_eq!(response.balance_nice, "10");
    assert_eq!(response.rate_nice, "2");
}