use std::fmt::{Debug, Formatter};
use base64::Engine;
use hedera::{AnyTransaction, PublicKey};
use serde::Deserialize;
use super::types::is_hbar_asset;
#[derive(Debug, thiserror::Error)]
pub enum HederaMirrorError {
#[error("hedera mirror error: {0}")]
Http(String),
#[error("hedera mirror parse error: {0}")]
Parse(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HederaAccountResolution {
pub exists: bool,
pub is_alias: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HederaPreflightResult {
pub ok: bool,
pub reason: Option<String>,
pub message: Option<String>,
}
impl HederaPreflightResult {
#[must_use]
pub const fn ok() -> Self {
Self {
ok: true,
reason: None,
message: None,
}
}
#[must_use]
pub fn fail(reason: impl Into<String>, message: impl Into<String>) -> Self {
Self {
ok: false,
reason: Some(reason.into()),
message: Some(message.into()),
}
}
#[must_use]
pub fn invalid_message(&self) -> Option<String> {
match (&self.reason, &self.message) {
(Some(reason), Some(message)) => Some(format!("{reason}: {message}")),
(Some(reason), None) => Some(reason.clone()),
(None, Some(message)) => Some(message.clone()),
(None, None) => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HederaSignatureResult {
pub ok: bool,
pub reason: Option<String>,
pub message: Option<String>,
}
impl HederaSignatureResult {
#[must_use]
pub const fn ok() -> Self {
Self {
ok: true,
reason: None,
message: None,
}
}
#[must_use]
pub fn fail(reason: impl Into<String>, message: impl Into<String>) -> Self {
Self {
ok: false,
reason: Some(reason.into()),
message: Some(message.into()),
}
}
#[must_use]
pub fn invalid_message(&self) -> Option<String> {
match (&self.reason, &self.message) {
(Some(reason), Some(message)) => Some(format!("{reason}: {message}")),
(Some(reason), None) => Some(reason.clone()),
(None, Some(message)) => Some(message.clone()),
(None, None) => None,
}
}
}
#[derive(Clone)]
pub struct HederaMirrorClient {
http: reqwest::Client,
base_url: String,
}
impl Debug for HederaMirrorClient {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HederaMirrorClient")
.field("base_url", &self.base_url)
.finish_non_exhaustive()
}
}
impl HederaMirrorClient {
#[must_use]
pub fn connect(base_url: impl Into<String>) -> Self {
Self {
http: reqwest::Client::new(),
base_url: trim_slash(base_url.into()),
}
}
pub async fn account(&self, account_id: &str) -> Result<MirrorAccount, HederaMirrorError> {
let url = format!(
"{}/api/v1/accounts/{}",
self.base_url,
urlencoding(account_id)
);
self.get_json(&url).await
}
pub async fn account_tokens(
&self,
account_id: &str,
token_id: Option<&str>,
) -> Result<MirrorTokensResponse, HederaMirrorError> {
let mut url = format!(
"{}/api/v1/accounts/{}/tokens",
self.base_url,
urlencoding(account_id)
);
if let Some(token_id) = token_id {
url.push_str("?token.id=");
url.push_str(&urlencoding(token_id));
}
self.get_json(&url).await
}
pub async fn get_path<T: for<'de> Deserialize<'de>>(
&self,
path: &str,
) -> Result<T, HederaMirrorError> {
let url = if path.starts_with("http://") || path.starts_with("https://") {
path.to_owned()
} else {
format!("{}{path}", self.base_url)
};
self.get_json(&url).await
}
async fn get_json<T: for<'de> Deserialize<'de>>(
&self,
url: &str,
) -> Result<T, HederaMirrorError> {
let response = self
.http
.get(url)
.send()
.await
.map_err(|e| HederaMirrorError::Http(e.to_string()))?;
let status = response.status();
if !status.is_success() {
return Err(HederaMirrorError::Http(format!(
"Mirror Node request failed with status {status}"
)));
}
response
.json()
.await
.map_err(|e| HederaMirrorError::Parse(e.to_string()))
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct MirrorAccount {
#[serde(default)]
pub balance: MirrorBalance,
#[serde(default)]
pub max_automatic_token_associations: i64,
#[serde(default)]
pub key: Option<MirrorAccountKey>,
#[serde(default)]
pub alias: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, Deserialize)]
pub struct MirrorBalance {
#[serde(default)]
pub balance: i64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct MirrorAccountKey {
#[serde(rename = "_type")]
pub key_type: String,
pub key: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct MirrorTokenRelationship {
pub token_id: String,
#[serde(default)]
pub balance: i64,
#[serde(default)]
pub automatic_association: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct MirrorTokensResponse {
#[serde(default)]
pub tokens: Vec<MirrorTokenRelationship>,
#[serde(default)]
pub links: MirrorLinks,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct MirrorLinks {
#[serde(default)]
pub next: Option<String>,
}
pub async fn preflight_transfer(
mirror: &HederaMirrorClient,
payer: &str,
pay_to: &str,
asset: &str,
amount: &str,
) -> Result<HederaPreflightResult, HederaMirrorError> {
let required = amount
.parse::<i128>()
.map_err(|e| HederaMirrorError::Parse(e.to_string()))?;
if is_hbar_asset(asset) {
let account = mirror.account(payer).await?;
let held = i128::from(account.balance.balance);
if held < required {
return Ok(HederaPreflightResult::fail(
"insufficient_balance",
format!("payer has {held} tinybars, needs {required}"),
));
}
return Ok(HederaPreflightResult::ok());
}
let payer_tokens = mirror.account_tokens(payer, Some(asset)).await?;
let held = payer_tokens
.tokens
.first()
.map_or(0, |t| i128::from(t.balance));
if held < required {
return Ok(HederaPreflightResult::fail(
"insufficient_balance",
format!("payer holds {held} of {asset}, needs {required}"),
));
}
if is_pay_to_associated(mirror, pay_to, asset).await? {
return Ok(HederaPreflightResult::ok());
}
Ok(HederaPreflightResult::fail(
"pay_to_not_associated",
format!("payTo {pay_to} is not associated with {asset} and has no auto-association slots"),
))
}
async fn is_pay_to_associated(
mirror: &HederaMirrorClient,
pay_to: &str,
asset: &str,
) -> Result<bool, HederaMirrorError> {
let direct = mirror.account_tokens(pay_to, Some(asset)).await?;
if !direct.tokens.is_empty() {
return Ok(true);
}
let account = mirror.account(pay_to).await?;
let max_auto = account.max_automatic_token_associations;
if max_auto == -1 {
return Ok(true);
}
if max_auto == 0 {
return Ok(false);
}
let mut consumed = 0i64;
let mut next = Some(format!("/api/v1/accounts/{pay_to}/tokens"));
while let Some(path) = next {
let page: MirrorTokensResponse = mirror.get_path(&path).await?;
let auto_count = page
.tokens
.iter()
.filter(|t| t.automatic_association)
.count();
let auto_count = i64::try_from(auto_count).unwrap_or(i64::MAX);
consumed = consumed.saturating_add(auto_count);
if consumed >= max_auto {
return Ok(false);
}
next = page.links.next;
}
Ok(consumed < max_auto)
}
pub async fn resolve_account(
mirror: &HederaMirrorClient,
account_id_or_alias: &str,
) -> Result<HederaAccountResolution, HederaMirrorError> {
match mirror.account(account_id_or_alias).await {
Ok(_) => Ok(HederaAccountResolution {
exists: true,
is_alias: !super::types::is_entity_id(account_id_or_alias),
}),
Err(HederaMirrorError::Http(msg)) if msg.contains("status 404") => {
Ok(HederaAccountResolution {
exists: false,
is_alias: !super::types::is_entity_id(account_id_or_alias),
})
}
Err(err) => Err(err),
}
}
pub async fn verify_payer_signature(
mirror: &HederaMirrorClient,
payer: &str,
transaction_base64: &str,
) -> Result<HederaSignatureResult, HederaMirrorError> {
let bytes = Engine::decode(
&base64::engine::general_purpose::STANDARD,
transaction_base64,
)
.map_err(|e| HederaMirrorError::Parse(e.to_string()))?;
let mut tx =
AnyTransaction::from_bytes(&bytes).map_err(|e| HederaMirrorError::Parse(e.to_string()))?;
let account = mirror.account(payer).await?;
let Some(mirror_key) = account.key.as_ref() else {
return Ok(HederaSignatureResult::fail(
"signature_invalid",
"could not resolve payer key",
));
};
let key = match parse_mirror_key(mirror_key) {
Ok(key) => key,
Err(err) => {
return Ok(HederaSignatureResult::fail("signature_unverifiable", err));
}
};
if key.signs(&mut tx) {
Ok(HederaSignatureResult::ok())
} else {
Ok(HederaSignatureResult::fail(
"signature_invalid",
format!("payer {payer} did not sign the transaction"),
))
}
}
#[derive(Debug, Clone)]
enum AccountKey {
Single(PublicKey),
List {
keys: Vec<Self>,
threshold: usize,
},
}
impl AccountKey {
fn signs(&self, tx: &mut AnyTransaction) -> bool {
match self {
Self::Single(pk) => pk.verify_transaction(tx).is_ok(),
Self::List { keys, threshold } => {
keys.iter().filter(|k| k.signs(tx)).count() >= *threshold
}
}
}
}
fn parse_mirror_key(mirror_key: &MirrorAccountKey) -> Result<AccountKey, String> {
if mirror_key.key.is_empty() {
return Err("could not resolve payer key".to_owned());
}
match mirror_key.key_type.as_str() {
"ED25519" => {
let bytes = decode_hex(&mirror_key.key)?;
PublicKey::from_bytes_ed25519(&bytes)
.map(AccountKey::Single)
.map_err(|e| e.to_string())
}
"ECDSA_SECP256K1" => {
let bytes = decode_hex(&mirror_key.key)?;
PublicKey::from_bytes_ecdsa(&bytes)
.map(AccountKey::Single)
.map_err(|e| e.to_string())
}
"ProtobufEncoded" => {
let bytes = decode_hex(&mirror_key.key)?;
parse_proto_key(&bytes)
}
other => Err(format!("unrecognized mirror key type {other}")),
}
}
fn decode_hex(s: &str) -> Result<Vec<u8>, String> {
let s = s.strip_prefix("0x").unwrap_or(s);
hex::decode(s).map_err(|_| "invalid hex key".to_owned())
}
fn parse_proto_key(bytes: &[u8]) -> Result<AccountKey, String> {
let mut rest = bytes;
let mut found = None;
while !rest.is_empty() {
let (tag, after_tag) = read_varint(rest)?;
rest = after_tag;
let field = tag >> 3;
let wire = tag & 7;
if wire != 2 {
return Err("unsupported protobuf key wire type".to_owned());
}
let (len, after_len) = read_varint(rest)?;
rest = after_len;
let len = usize::try_from(len).map_err(|_| "invalid protobuf key length".to_owned())?;
if rest.len() < len {
return Err("truncated protobuf key".to_owned());
}
let (payload, after_payload) = rest.split_at(len);
rest = after_payload;
found = Some(match field {
2 => AccountKey::Single(
PublicKey::from_bytes_ed25519(payload).map_err(|e| e.to_string())?,
),
5 => parse_threshold_key(payload)?,
6 => parse_key_list(payload, None)?,
7 => {
AccountKey::Single(PublicKey::from_bytes_ecdsa(payload).map_err(|e| e.to_string())?)
}
1 | 3 | 4 | 8 => return Err("unsupported account key kind".to_owned()),
_ => continue,
});
}
found.ok_or_else(|| "empty protobuf key".to_owned())
}
fn parse_threshold_key(bytes: &[u8]) -> Result<AccountKey, String> {
let mut rest = bytes;
let mut threshold = 0u32;
let mut list = None;
while !rest.is_empty() {
let (tag, after_tag) = read_varint(rest)?;
rest = after_tag;
let field = tag >> 3;
let wire = tag & 7;
match (field, wire) {
(1, 0) => {
let (value, after) = read_varint(rest)?;
rest = after;
threshold = u32::try_from(value).map_err(|_| "invalid threshold".to_owned())?;
}
(2, 2) => {
let (len, after_len) = read_varint(rest)?;
rest = after_len;
let len =
usize::try_from(len).map_err(|_| "invalid protobuf key length".to_owned())?;
if rest.len() < len {
return Err("truncated threshold key list".to_owned());
}
let (payload, after_payload) = rest.split_at(len);
rest = after_payload;
list = Some(payload);
}
(_, 2) => {
let (len, after_len) = read_varint(rest)?;
rest = after_len;
let len =
usize::try_from(len).map_err(|_| "invalid protobuf key length".to_owned())?;
rest = rest
.get(len..)
.ok_or_else(|| "truncated protobuf key".to_owned())?;
}
(_, 0) => {
let (_, after) = read_varint(rest)?;
rest = after;
}
_ => return Err("unsupported threshold key field".to_owned()),
}
}
let Some(list) = list else {
return Err("threshold key missing key list".to_owned());
};
let threshold = if threshold == 0 {
None
} else {
Some(threshold)
};
parse_key_list(list, threshold)
}
fn parse_key_list(bytes: &[u8], threshold: Option<u32>) -> Result<AccountKey, String> {
let mut rest = bytes;
let mut keys = Vec::new();
while !rest.is_empty() {
let (tag, after_tag) = read_varint(rest)?;
rest = after_tag;
let field = tag >> 3;
let wire = tag & 7;
if field != 1 || wire != 2 {
return Err("unexpected key list field".to_owned());
}
let (len, after_len) = read_varint(rest)?;
rest = after_len;
let len = usize::try_from(len).map_err(|_| "invalid protobuf key length".to_owned())?;
if rest.len() < len {
return Err("truncated key list entry".to_owned());
}
let (payload, after_payload) = rest.split_at(len);
rest = after_payload;
keys.push(parse_proto_key(payload)?);
}
let threshold = threshold.map_or(keys.len(), |t| t as usize);
Ok(AccountKey::List { keys, threshold })
}
fn read_varint(bytes: &[u8]) -> Result<(u64, &[u8]), String> {
let mut value = 0u64;
let mut shift = 0u32;
for (i, b) in bytes.iter().copied().enumerate() {
let bits = u64::from(b & 0x7f);
value |= bits
.checked_shl(shift)
.ok_or_else(|| "varint overflow".to_owned())?;
if b & 0x80 == 0 {
let rest = bytes
.get(i.saturating_add(1)..)
.ok_or_else(|| "truncated varint".to_owned())?;
return Ok((value, rest));
}
shift = shift.saturating_add(7);
if shift > 63 {
return Err("varint overflow".to_owned());
}
}
Err("truncated varint".to_owned())
}
fn trim_slash(mut s: String) -> String {
while s.ends_with('/') {
s.pop();
}
s
}
fn urlencoding(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(char::from(b));
}
_ => {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
out.push('%');
let hi = HEX.get(usize::from(b >> 4)).copied().unwrap_or(b'0');
let lo = HEX.get(usize::from(b & 0x0f)).copied().unwrap_or(b'0');
out.push(char::from(hi));
out.push(char::from(lo));
}
}
}
out
}
#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "test assertions")]
mod tests {
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
#[tokio::test]
async fn hbar_preflight_ok() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/accounts/0.0.9001"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"balance": { "balance": 5000 },
"max_automatic_token_associations": 0
})))
.mount(&server)
.await;
let mirror = HederaMirrorClient::connect(server.uri());
let result = preflight_transfer(&mirror, "0.0.9001", "0.0.7001", "0.0.0", "1000")
.await
.unwrap();
assert!(result.ok);
}
#[tokio::test]
async fn hbar_preflight_insufficient() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/accounts/0.0.9001"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"balance": { "balance": 500 },
"max_automatic_token_associations": 0
})))
.mount(&server)
.await;
let mirror = HederaMirrorClient::connect(server.uri());
let result = preflight_transfer(&mirror, "0.0.9001", "0.0.7001", "0.0.0", "1000")
.await
.unwrap();
assert!(!result.ok);
assert_eq!(result.reason.as_deref(), Some("insufficient_balance"));
}
}