use crate::error::{Error, Result};
use base64::Engine;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;
pub const MODCHAIN_ZANO: &str = "https://rpc.modchain.net/chain/zano";
const MAX_RESPONSE: usize = 64 << 20;
pub struct Client {
endpoint: String,
asset_cache: Mutex<HashMap<String, AssetDescriptor>>,
}
impl Client {
pub fn new(endpoint: &str) -> Client {
Client {
endpoint: endpoint.to_string(),
asset_cache: Mutex::new(HashMap::new()),
}
}
pub fn endpoint(&self) -> &str {
&self.endpoint
}
pub fn json_rpc_url(&self) -> String {
if self.endpoint.is_empty() {
return format!("{MODCHAIN_ZANO}/rpc");
}
format!("{}/json_rpc", self.endpoint.trim_end_matches('/'))
}
pub fn bin_url(&self, method: &str) -> String {
if self.endpoint.is_empty() {
return format!("{MODCHAIN_ZANO}/raw/{method}.bin");
}
format!("{}/{method}.bin", self.endpoint.trim_end_matches('/'))
}
fn post(&self, url: &str, content_type: &str, body: Vec<u8>) -> Result<Vec<u8>> {
let resp = rsurl::Request::new("POST", url)
.map_err(|e| crate::err!("zano rpc: bad url {url}: {e}"))?
.header("Content-Type", content_type)
.body(body)
.send()
.map_err(|e| crate::err!("zano rpc: {url}: {e}"))?;
if resp.body.len() > MAX_RESPONSE {
return Err(Error::msg("zano rpc: response too large"));
}
if resp.status != 200 {
return Err(crate::err!(
"zano rpc: http {}: {}",
resp.status,
String::from_utf8_lossy(&resp.body)
));
}
Ok(resp.body)
}
pub fn call<P: Serialize, R: DeserializeOwned>(&self, method: &str, params: &P) -> Result<R> {
let req = RpcRequest {
jsonrpc: "2.0",
id: 0,
method,
params,
};
let body = serde_json::to_vec(&req)?;
let data = self.post(&self.json_rpc_url(), "application/json", body)?;
let env: RpcResponse = serde_json::from_slice(&data)
.map_err(|e| crate::err!("zano rpc {method}: decode envelope: {e}"))?;
if let Some(err) = env.error {
return Err(crate::err!(
"zano rpc {method}: zano rpc error {}: {}",
err.code,
err.message
));
}
let result = env
.result
.ok_or_else(|| crate::err!("zano rpc {method}: no result in response"))?;
serde_json::from_str(result.get())
.map_err(|e| crate::err!("zano rpc {method}: decode result: {e}"))
}
pub fn post_bin(&self, method: &str, body: Vec<u8>) -> Result<Vec<u8>> {
self.post(&self.bin_url(method), "application/octet-stream", body)
}
pub fn get_block_count(&self) -> Result<u64> {
#[derive(Deserialize)]
struct Res {
count: u64,
}
let res: Res = self.call("getblockcount", &serde_json::json!({}))?;
Ok(res.count)
}
pub fn get_blocks_details(
&self,
start: u64,
count: u64,
ignore_tx: bool,
) -> Result<Vec<BlockDetails>> {
#[derive(Serialize)]
struct Params {
height_start: u64,
count: u64,
ignore_transactions: bool,
}
#[derive(Deserialize)]
struct Res {
#[serde(default)]
blocks: Vec<BlockDetails>,
}
let res: Res = self.call(
"get_blocks_details",
&Params {
height_start: start,
count,
ignore_transactions: ignore_tx,
},
)?;
Ok(res.blocks)
}
pub fn get_tx_details(&self, tx_hash: &str) -> Result<TxDetails> {
#[derive(Serialize)]
struct Params<'a> {
tx_hash: &'a str,
}
#[derive(Deserialize)]
struct Res {
tx_info: TxDetailsRaw,
}
#[derive(Deserialize)]
struct TxDetailsRaw {
#[serde(default)]
id: String,
#[serde(default)]
keeper_block: u64,
#[serde(default)]
blob: String,
#[serde(default)]
outs: Vec<TxOutInfo>,
}
let res: Res = self.call("get_tx_details", &Params { tx_hash })?;
let blob = base64::engine::general_purpose::STANDARD
.decode(res.tx_info.blob.as_bytes())
.map_err(|e| crate::err!("get_tx_details {tx_hash}: decode blob: {e}"))?;
Ok(TxDetails {
id: res.tx_info.id,
keeper_block: res.tx_info.keeper_block,
blob,
outs: res.tx_info.outs,
})
}
pub fn send_raw_tx(&self, raw: &[u8]) -> Result<String> {
#[derive(Serialize)]
struct Params {
tx_as_base64: String,
}
#[derive(Deserialize)]
struct Res {
#[serde(default)]
status: String,
}
let res: Res = self.call(
"sendrawtransaction",
&Params {
tx_as_base64: base64::engine::general_purpose::STANDARD.encode(raw),
},
)?;
Ok(res.status)
}
pub fn get_asset_info(&self, asset_id_hex: &str) -> Result<AssetDescriptor> {
if let Some(d) = self
.asset_cache
.lock()
.expect("asset cache mutex")
.get(asset_id_hex)
{
return Ok(d.clone());
}
#[derive(Serialize)]
struct Params<'a> {
asset_id: &'a str,
}
#[derive(Deserialize)]
struct Res {
asset_descriptor: AssetDescriptor,
}
let res: Res = self.call(
"get_asset_info",
&Params {
asset_id: asset_id_hex,
},
)?;
self.asset_cache
.lock()
.expect("asset cache mutex")
.insert(asset_id_hex.to_string(), res.asset_descriptor.clone());
Ok(res.asset_descriptor)
}
}
#[derive(Serialize)]
struct RpcRequest<'a, P> {
jsonrpc: &'a str,
id: u32,
method: &'a str,
params: &'a P,
}
#[derive(Deserialize)]
struct RpcResponse {
#[serde(default)]
result: Option<Box<serde_json::value::RawValue>>,
#[serde(default)]
error: Option<RpcError>,
}
#[derive(Deserialize)]
struct RpcError {
#[serde(default)]
code: i64,
#[serde(default)]
message: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct BlockDetails {
#[serde(default)]
pub height: u64,
#[serde(default, rename = "transactions_details")]
pub transactions: Vec<TxBrief>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct TxBrief {
#[serde(default)]
pub id: String,
}
#[derive(Clone, Debug)]
pub struct TxDetails {
pub id: String,
pub keeper_block: u64,
pub blob: Vec<u8>,
pub outs: Vec<TxOutInfo>,
}
#[derive(Clone, Copy, Debug, Deserialize)]
pub struct TxOutInfo {
#[serde(default)]
pub global_index: u64,
#[serde(default)]
pub is_spent: bool,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct AssetDescriptor {
#[serde(default)]
pub ticker: String,
#[serde(default)]
pub full_name: String,
#[serde(default)]
pub decimal_point: u8,
#[serde(default)]
pub current_supply: u64,
#[serde(default)]
pub total_max_supply: u64,
#[serde(default)]
pub hidden_supply: bool,
#[serde(default)]
pub owner: String,
#[serde(default)]
pub owner_eth_pub_key: String,
#[serde(default)]
pub meta_info: String,
}
impl AssetDescriptor {
pub fn format_amount(&self, atomic: u64) -> String {
format_atomic(atomic, self.decimal_point)
}
}
pub fn format_atomic(atomic: u64, decimals: u8) -> String {
let s = atomic.to_string();
if decimals == 0 {
return s;
}
let d = decimals as usize;
let s = if s.len() <= d {
format!("{}{}", "0".repeat(d - s.len() + 1), s)
} else {
s
};
let split = s.len() - d;
format!("{}.{}", &s[..split], &s[split..])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn urls_follow_the_endpoint_layout() {
let direct = Client::new("http://127.0.0.1:11211");
assert_eq!(direct.json_rpc_url(), "http://127.0.0.1:11211/json_rpc");
assert_eq!(
direct.bin_url("getrandom_outs3"),
"http://127.0.0.1:11211/getrandom_outs3.bin"
);
let slash = Client::new("http://127.0.0.1:11211/");
assert_eq!(slash.json_rpc_url(), "http://127.0.0.1:11211/json_rpc");
assert_eq!(
slash.bin_url("getrandom_outs3"),
"http://127.0.0.1:11211/getrandom_outs3.bin"
);
let gw = Client::new("");
assert_eq!(gw.json_rpc_url(), format!("{MODCHAIN_ZANO}/rpc"));
assert_eq!(
gw.bin_url("getrandom_outs3"),
format!("{MODCHAIN_ZANO}/raw/getrandom_outs3.bin")
);
}
#[test]
fn format_atomic_pads_and_splits() {
assert_eq!(format_atomic(10000, 4), "1.0000");
assert_eq!(format_atomic(1, 4), "0.0001");
assert_eq!(format_atomic(0, 4), "0.0000");
assert_eq!(format_atomic(123456789, 8), "1.23456789");
assert_eq!(format_atomic(42, 0), "42");
}
}