use std::collections::BTreeMap;
use std::str::FromStr;
use base64::Engine;
use hedera::{AccountId, AnyTransaction, Hbar, TokenId, TransferTransaction};
use super::types::{hedera_account_ids_equal, is_hbar_asset};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HederaTransferEntry {
pub account_id: String,
pub amount: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InspectedHederaTransaction {
pub transaction_type: String,
pub transaction_id: String,
pub transaction_id_account_id: String,
pub has_non_transfer_operations: bool,
pub hbar_transfers: Vec<HederaTransferEntry>,
pub token_transfers: BTreeMap<String, Vec<HederaTransferEntry>>,
}
pub fn inspect_hedera_transaction(
transaction_base64: &str,
) -> Result<InspectedHederaTransaction, String> {
let bytes = Engine::decode(
&base64::engine::general_purpose::STANDARD,
transaction_base64,
)
.map_err(|e| e.to_string())?;
inspect_hedera_transaction_bytes(&bytes)
}
pub fn inspect_hedera_transaction_bytes(
bytes: &[u8],
) -> Result<InspectedHederaTransaction, String> {
let any = AnyTransaction::from_bytes(bytes).map_err(|e| e.to_string())?;
let Some(transaction_id) = any.get_transaction_id() else {
return Err("invalid_hedera_transaction_metadata".to_owned());
};
let transaction_id_account_id = transaction_id.account_id.to_string();
if transaction_id_account_id.is_empty() {
return Err("invalid_hedera_transaction_metadata".to_owned());
}
let transaction_id_str = transaction_id.to_string();
match any.downcast::<TransferTransaction>() {
Ok(tx) => {
let hbar_transfers = normalize_hbar_transfers(&tx);
let mut token_transfers = normalize_token_transfers(&tx);
for (token_id, nfts) in tx.get_nft_transfers() {
if nfts.is_empty() {
continue;
}
let _ = token_transfers.entry(token_id.to_string()).or_default();
}
Ok(InspectedHederaTransaction {
transaction_type: "TransferTransaction".to_owned(),
transaction_id: transaction_id_str,
transaction_id_account_id,
has_non_transfer_operations: false,
hbar_transfers,
token_transfers,
})
}
Err(_) => Ok(InspectedHederaTransaction {
transaction_type: "Other".to_owned(),
transaction_id: transaction_id_str,
transaction_id_account_id,
has_non_transfer_operations: true,
hbar_transfers: Vec::new(),
token_transfers: BTreeMap::new(),
}),
}
}
fn normalize_hbar_transfers(tx: &TransferTransaction) -> Vec<HederaTransferEntry> {
tx.get_hbar_transfers()
.into_iter()
.map(|(account_id, amount)| HederaTransferEntry {
account_id: account_id.to_string(),
amount: amount.to_tinybars().to_string(),
})
.collect()
}
fn normalize_token_transfers(
tx: &TransferTransaction,
) -> BTreeMap<String, Vec<HederaTransferEntry>> {
tx.get_token_transfers()
.into_iter()
.map(|(token_id, accounts)| {
let entries = accounts
.into_iter()
.map(|(account_id, amount)| HederaTransferEntry {
account_id: account_id.to_string(),
amount: amount.to_string(),
})
.collect();
(token_id.to_string(), entries)
})
.collect()
}
#[must_use]
pub fn sum_transfers(transfers: &[HederaTransferEntry]) -> i128 {
transfers.iter().map(|e| parse_i128(&e.amount)).sum()
}
#[must_use]
pub fn get_positive_receivers(transfers: &[HederaTransferEntry]) -> Vec<String> {
let mut net: BTreeMap<String, i128> = BTreeMap::new();
for entry in transfers {
*net.entry(entry.account_id.clone()).or_insert(0) += parse_i128(&entry.amount);
}
net.into_iter()
.filter(|(_, value)| *value > 0)
.map(|(account_id, _)| account_id)
.collect()
}
fn parse_i128(s: &str) -> i128 {
s.parse().unwrap_or(0)
}
#[must_use]
pub fn asset_transfers<'a>(
inspected: &'a InspectedHederaTransaction,
asset: &str,
) -> Option<&'a [HederaTransferEntry]> {
if is_hbar_asset(asset) {
if !inspected.token_transfers.is_empty() {
return None;
}
return Some(inspected.hbar_transfers.as_slice());
}
if inspected.token_transfers.len() != 1 {
return None;
}
inspected.token_transfers.get(asset).map(Vec::as_slice)
}
#[must_use]
pub fn infer_payers(transfers: &[HederaTransferEntry]) -> Vec<(String, String)> {
let mut debited: BTreeMap<String, i128> = BTreeMap::new();
for entry in transfers {
let amount = parse_i128(&entry.amount);
if amount < 0 {
*debited.entry(entry.account_id.clone()).or_insert(0) += amount;
}
}
debited
.into_iter()
.map(|(account_id, amount)| (account_id, (-amount).to_string()))
.collect()
}
#[must_use]
pub fn has_negative_transfer(transfers: &[HederaTransferEntry], account_id: &str) -> bool {
transfers.iter().any(|entry| {
hedera_account_ids_equal(&entry.account_id, account_id) && parse_i128(&entry.amount) < 0
})
}
pub fn create_hedera_client(
network: &str,
node_url: Option<&str>,
) -> Result<hedera::Client, String> {
if let Some(node_url) = node_url {
let mut map = std::collections::HashMap::new();
let _ = map.insert(
node_url.to_owned(),
AccountId::from_str("0.0.3").map_err(|e| e.to_string())?,
);
return hedera::Client::for_network(map).map_err(|e| e.to_string());
}
match network {
"hedera:mainnet" => Ok(hedera::Client::for_mainnet()),
"hedera:testnet" => Ok(hedera::Client::for_testnet()),
other => Err(format!("Unsupported Hedera network: {other}")),
}
}
#[allow(
clippy::too_many_arguments,
clippy::large_types_passed_by_value,
reason = "Hiero AccountId is Copy but includes optional key material"
)]
pub fn create_partially_signed_transfer(
payer: AccountId,
private_key: &hedera::PrivateKey,
fee_payer: AccountId,
pay_to: AccountId,
asset: &str,
amount: i64,
network: &str,
node_url: Option<&str>,
) -> Result<String, String> {
if amount <= 0 {
return Err("amount must be greater than zero".to_owned());
}
let mut tx = TransferTransaction::new();
if is_hbar_asset(asset) {
let _ = tx
.hbar_transfer(payer, Hbar::from_tinybars(-amount))
.hbar_transfer(pay_to, Hbar::from_tinybars(amount));
} else {
let token_id = TokenId::from_str(asset).map_err(|e| e.to_string())?;
let _ = tx
.token_transfer(token_id, payer, -amount)
.token_transfer(token_id, pay_to, amount);
}
let _ = tx.transaction_id(hedera::TransactionId::generate(fee_payer));
if let Some(node_url) = node_url {
let client = create_hedera_client(network, Some(node_url))?;
tx.freeze_with(&client).map_err(|e| e.to_string())?;
} else {
let _ = tx.node_account_ids([AccountId::new(0, 0, 3)]);
tx.freeze().map_err(|e| e.to_string())?;
}
let _ = tx.sign(private_key.clone());
let bytes = tx.to_bytes().map_err(|e| e.to_string())?;
Ok(Engine::encode(
&base64::engine::general_purpose::STANDARD,
bytes,
))
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
reason = "test assertions"
)]
mod tests {
use hedera::{AccountId, Hbar, PrivateKey, TokenId, TopicCreateTransaction, TransactionId};
use super::*;
fn transfer_b64(args: (&str, &str, &str, &str, &str)) -> String {
let (fee_payer, payer, pay_to, asset, amount) = args;
let amount: i64 = amount.parse().unwrap();
let mut tx = TransferTransaction::new();
if asset == "0.0.0" {
let _ = tx
.hbar_transfer(
AccountId::from_str(payer).unwrap(),
Hbar::from_tinybars(-amount),
)
.hbar_transfer(
AccountId::from_str(pay_to).unwrap(),
Hbar::from_tinybars(amount),
);
} else {
let token = TokenId::from_str(asset).unwrap();
let _ = tx
.token_transfer(token, AccountId::from_str(payer).unwrap(), -amount)
.token_transfer(token, AccountId::from_str(pay_to).unwrap(), amount);
}
let _ = tx
.transaction_id(TransactionId::generate(
AccountId::from_str(fee_payer).unwrap(),
))
.node_account_ids([AccountId::new(0, 0, 3)]);
tx.freeze().unwrap();
Engine::encode(
&base64::engine::general_purpose::STANDARD,
tx.to_bytes().unwrap(),
)
}
#[test]
fn inspects_token_transfer() {
let b64 = transfer_b64(("0.0.5001", "0.0.9001", "0.0.7001", "0.0.6001", "1000"));
let inspected = inspect_hedera_transaction(&b64).unwrap();
assert_eq!(inspected.transaction_type, "TransferTransaction");
assert_eq!(inspected.transaction_id_account_id, "0.0.5001");
assert!(!inspected.has_non_transfer_operations);
let transfers = asset_transfers(&inspected, "0.0.6001").unwrap();
assert_eq!(sum_transfers(transfers), 0);
let payers = infer_payers(transfers);
assert_eq!(payers.len(), 1);
assert_eq!(payers[0].0, "0.0.9001");
assert_eq!(payers[0].1, "1000");
}
#[test]
fn inspects_non_transfer() {
let mut tx = TopicCreateTransaction::new();
let _ = tx
.transaction_id(TransactionId::generate(
AccountId::from_str("0.0.5001").unwrap(),
))
.node_account_ids([AccountId::new(0, 0, 3)]);
let key = PrivateKey::generate_ed25519();
let _ = tx.submit_key(key.public_key());
tx.freeze().unwrap();
let b64 = Engine::encode(
&base64::engine::general_purpose::STANDARD,
tx.to_bytes().unwrap(),
);
let inspected = inspect_hedera_transaction(&b64).unwrap();
assert!(inspected.has_non_transfer_operations);
}
#[test]
fn decode_rejects_garbage() {
assert!(inspect_hedera_transaction("not-a-valid-hedera-transaction").is_err());
}
}