use std::collections::HashMap;
use bigdecimal::BigDecimal;
use prost_types::Timestamp;
use zera_proto::zera_txn::{
BaseTxn, CoinTxn, InputTransfers, OutputTransfers, PublicKey, TransferAuthentication,
};
use crate::api::get_nonces_with_client;
use crate::crypto::address::{
generate_address_from_public_key, get_public_key_bytes, sanitize_and_decode_address,
};
use crate::error::{Result, ZeraError};
use crate::fees::{FeeConfig, FeeConfigHelper, UniversalFeeCalculator};
use crate::grpc::{submit_transaction, UnaryTransport, ValidatorApiClient};
use crate::sign::{create_transaction_hash, sign_coin_txn_with_keys, CoinTxnKeyPair};
use crate::types::RpcConfig;
use crate::utils::amount::{to_decimal, to_smallest_units, validate_exact_amount_balance};
use crate::utils::token::{get_token_info_map_with_client, normalize_contract_id};
use crate::utils::validation::is_valid_contract_id;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoinTxnBuildInput {
pub public_key: Option<String>,
pub amount: Option<String>,
pub fee_percent: Option<String>,
pub allowance_address: Option<String>,
pub nonce: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoinTxnInput {
pub private_key: Option<String>,
pub public_key: Option<String>,
pub amount: Option<String>,
pub fee_percent: Option<String>,
pub allowance_address: Option<String>,
pub nonce: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoinTxnOutput {
pub to: String,
pub amount: String,
pub memo: Option<String>,
}
pub async fn build_coin_txn(
inputs: &[CoinTxnBuildInput],
outputs: &[CoinTxnOutput],
contract_id: &str,
fee_config: FeeConfig,
base_memo: Option<String>,
grpc_config: Option<RpcConfig>,
) -> Result<CoinTxn> {
let config = grpc_config
.or_else(|| fee_config.grpc_config.clone())
.unwrap_or_default();
let client = ValidatorApiClient::new(config)?;
build_coin_txn_with_client(inputs, outputs, contract_id, fee_config, base_memo, &client).await
}
pub async fn build_coin_txn_with_client<T>(
inputs: &[CoinTxnBuildInput],
outputs: &[CoinTxnOutput],
contract_id: &str,
fee_config: FeeConfig,
base_memo: Option<String>,
client: &ValidatorApiClient<T>,
) -> Result<CoinTxn>
where
T: UnaryTransport,
{
let normalized_contract_id = normalize_contract_id(contract_id);
validate_transaction_requirements(inputs, outputs, &normalized_contract_id)?;
let normalized_fee_config = FeeConfig {
base_fee_id: fee_config.base_fee_id.as_deref().map(normalize_contract_id),
contract_fee_id: fee_config
.contract_fee_id
.as_deref()
.map(normalize_contract_id),
interface_fee_id: fee_config
.interface_fee_id
.as_deref()
.map(normalize_contract_id),
..fee_config.clone()
};
let additional_ids: Vec<String> = [
normalized_fee_config.contract_fee_id.clone(),
normalized_fee_config.interface_fee_id.clone(),
normalized_fee_config.base_fee_id.clone(),
]
.into_iter()
.flatten()
.collect();
let token_info_map =
get_token_info_map_with_client(&normalized_contract_id, &additional_ids, client).await?;
let ProcessedInputs {
public_keys,
input_transfers,
nonces,
allowance_addresses,
allowance_nonces,
} = process_unsigned_inputs(inputs, &normalized_contract_id, &token_info_map, client).await?;
let validation_inputs = if allowance_addresses.is_empty() {
inputs.to_vec()
} else {
inputs.iter().skip(1).cloned().collect()
};
let output_transfers = process_outputs(outputs, &token_info_map, &normalized_contract_id)?;
validate_transaction_balance(
&validation_inputs,
outputs,
&normalized_contract_id,
&token_info_map,
)?;
validate_fee_percentages(&input_transfers)?;
let mut coin_txn = CoinTxn {
base: Some(create_base_transaction(
normalized_fee_config
.base_fee_id
.as_deref()
.unwrap_or("$ZRA+0000"),
"1",
base_memo,
)?),
contract_id: normalized_contract_id,
auth: Some(create_transfer_auth(
public_keys,
Vec::new(),
nonces,
allowance_addresses,
allowance_nonces,
)),
input_transfers,
output_transfers,
..Default::default()
};
coin_txn = UniversalFeeCalculator::calculate_fee_with_client(
FeeConfigHelper {
contract_id: Some(coin_txn.contract_id.clone()),
proto_object: coin_txn,
token_info_map,
base_fee_id: normalized_fee_config.base_fee_id,
base_fee: normalized_fee_config.base_fee,
contract_fee_id: normalized_fee_config.contract_fee_id,
contract_fee: normalized_fee_config.contract_fee,
interface_fee_id: normalized_fee_config.interface_fee_id,
interface_fee: normalized_fee_config.interface_fee,
interface_address: normalized_fee_config.interface_address,
overestimate_percent: normalized_fee_config.overestimate_percent,
gas_fee_in_usd: normalized_fee_config.gas_fee_in_usd,
grpc_config: None,
needs_initialization: normalized_fee_config.needs_initialization,
},
client,
)
.await?;
Ok(coin_txn)
}
pub async fn create_coin_txn(
inputs: &[CoinTxnInput],
outputs: &[CoinTxnOutput],
contract_id: &str,
fee_config: FeeConfig,
base_memo: Option<String>,
grpc_config: Option<RpcConfig>,
) -> Result<CoinTxn> {
let build_inputs: Vec<CoinTxnBuildInput> = inputs
.iter()
.map(|input| CoinTxnBuildInput {
public_key: input.public_key.clone(),
amount: input.amount.clone(),
fee_percent: input.fee_percent.clone(),
allowance_address: input.allowance_address.clone(),
nonce: input.nonce,
})
.collect();
let signer_keys: Vec<CoinTxnKeyPair> = inputs
.iter()
.filter(|input| input.allowance_address.is_none())
.map(|input| CoinTxnKeyPair {
public_key: input.public_key.clone().unwrap_or_default(),
private_key: input.private_key.clone().unwrap_or_default(),
})
.collect();
let mut coin_txn = build_coin_txn(
&build_inputs,
outputs,
contract_id,
fee_config,
base_memo,
grpc_config,
)
.await?;
if signer_keys.is_empty() {
let bytes = prost::Message::encode_to_vec(&coin_txn);
if let Some(base) = coin_txn.base.as_mut() {
base.hash = Some(create_transaction_hash(&bytes));
}
} else {
sign_coin_txn_with_keys(&mut coin_txn, &signer_keys)?;
}
Ok(coin_txn)
}
pub async fn send_coin_txn(coin_txn: &CoinTxn, grpc_config: Option<RpcConfig>) -> Result<String> {
submit_transaction(coin_txn, grpc_config.unwrap_or_default()).await
}
fn validate_transaction_requirements(
inputs: &[CoinTxnBuildInput],
outputs: &[CoinTxnOutput],
contract_id: &str,
) -> Result<()> {
if inputs.is_empty() || outputs.is_empty() {
return Err(ZeraError::Validation(
"Must have at least one input and one output".to_string(),
));
}
if !is_valid_contract_id(contract_id) {
return Err(ZeraError::Validation(
"ContractId must be provided and follow the format $[letters]+[4 digits] (e.g., $ZRA+0000)"
.to_string(),
));
}
Ok(())
}
fn validate_transaction_balance(
inputs: &[CoinTxnBuildInput],
outputs: &[CoinTxnOutput],
contract_id: &str,
token_info_map: &HashMap<String, crate::api::TokenInfo>,
) -> Result<()> {
let denomination = token_info_map
.get(contract_id)
.map(|token| token.denomination.as_str());
let input_amounts: Result<Vec<String>> = inputs
.iter()
.map(|input| {
let amount = input.amount.as_deref().ok_or_else(|| {
ZeraError::Validation(format!(
"Input at index {} must have a defined amount",
inputs
.iter()
.position(|candidate| std::ptr::eq(candidate, input))
.unwrap_or(0)
))
})?;
to_smallest_units(amount, contract_id, denomination, Some(token_info_map))
})
.collect();
let output_amounts: Result<Vec<String>> = outputs
.iter()
.map(|output| {
to_smallest_units(
&output.amount,
contract_id,
denomination,
Some(token_info_map),
)
})
.collect();
validate_exact_amount_balance(&input_amounts?, &output_amounts?)
}
fn validate_fee_percentages(input_transfers: &[InputTransfers]) -> Result<()> {
let total = input_transfers
.iter()
.fold(BigDecimal::from(0u32), |sum, transfer| {
sum + BigDecimal::from(transfer.fee_percent)
});
if total != 100_000_000u32 {
return Err(ZeraError::Validation(format!(
"Fee percentages must sum to exactly 100% (100,000,000). Current sum: {}",
total.normalized()
)));
}
Ok(())
}
struct ProcessedInputs {
public_keys: Vec<PublicKey>,
input_transfers: Vec<InputTransfers>,
nonces: Vec<u64>,
allowance_addresses: Vec<Vec<u8>>,
allowance_nonces: Vec<u64>,
}
async fn process_unsigned_inputs<T>(
inputs: &[CoinTxnBuildInput],
contract_id: &str,
token_info_map: &HashMap<String, crate::api::TokenInfo>,
client: &ValidatorApiClient<T>,
) -> Result<ProcessedInputs>
where
T: UnaryTransport,
{
let mut public_keys = Vec::new();
let mut input_transfers = Vec::new();
let mut addresses = Vec::new();
let mut is_allowance = false;
let all_inputs_have_nonces = inputs.iter().all(|input| input.nonce.is_some());
for (index, input) in inputs.iter().enumerate() {
if input.public_key.is_none() && input.allowance_address.is_none() {
return Err(ZeraError::Validation(format!(
"Input {index} is missing publicKey"
)));
} else if input.allowance_address.is_some() {
is_allowance = true;
}
let address = if let Some(public_key) = &input.public_key {
generate_address_from_public_key(public_key)?
} else {
input.allowance_address.clone().unwrap_or_default()
};
addresses.push(address);
}
let nonce_values = if all_inputs_have_nonces {
inputs
.iter()
.map(|input| input.nonce.unwrap_or(0))
.collect()
} else {
get_nonces_with_client(&addresses, client).await?
};
let (final_nonces, allowance_nonces, allowance_addresses) = if is_allowance {
(
nonce_values.iter().take(1).copied().collect(),
nonce_values.iter().skip(1).copied().collect(),
addresses
.iter()
.skip(1)
.map(|address| sanitize_and_decode_address(address))
.collect::<Result<Vec<_>>>()?,
)
} else {
(nonce_values.clone(), Vec::new(), Vec::new())
};
for (index, input) in inputs.iter().enumerate() {
if let Some(public_key) = &input.public_key {
public_keys.push(PublicKey {
single: get_public_key_bytes(public_key)?,
..Default::default()
});
} else if !is_allowance {
return Err(ZeraError::Validation(format!(
"Input {index} is missing publicKey"
)));
}
if is_allowance && input.public_key.is_some() {
continue;
}
let amount = input.amount.as_deref().ok_or_else(|| {
if input.allowance_address.is_some() {
ZeraError::Validation(format!(
"Allowance input at index {index} must specify an amount"
))
} else {
ZeraError::Validation(format!("Input at index {index} must specify an amount"))
}
})?;
let denomination = token_info_map
.get(contract_id)
.map(|token| token.denomination.as_str());
let final_amount =
to_smallest_units(amount, contract_id, denomination, Some(token_info_map))?;
let fee_percent = input.fee_percent.as_deref().unwrap_or("100");
let scaled_fee_percent = (to_decimal(fee_percent)? * BigDecimal::from(1_000_000u32))
.with_scale_round(0, bigdecimal::RoundingMode::Down)
.to_string()
.parse::<u32>()
.map_err(|error| {
ZeraError::Serialization(format!("Invalid fee percent \"{fee_percent}\": {error}"))
})?;
input_transfers.push(InputTransfers {
index: index as u64,
amount: final_amount,
fee_percent: scaled_fee_percent,
contract_fee_percent: None,
});
}
Ok(ProcessedInputs {
public_keys,
input_transfers,
nonces: final_nonces,
allowance_addresses,
allowance_nonces,
})
}
fn process_outputs(
outputs: &[CoinTxnOutput],
token_info_map: &HashMap<String, crate::api::TokenInfo>,
contract_id: &str,
) -> Result<Vec<OutputTransfers>> {
outputs
.iter()
.map(|output| {
let denomination = token_info_map
.get(contract_id)
.map(|token| token.denomination.as_str());
let amount = to_smallest_units(
&output.amount,
contract_id,
denomination,
Some(token_info_map),
)?;
Ok(OutputTransfers {
wallet_address: sanitize_and_decode_address(&output.to)?,
amount,
memo: output.memo.clone(),
})
})
.collect()
}
fn create_base_transaction(
base_fee_id: &str,
base_fee: &str,
base_memo: Option<String>,
) -> Result<BaseTxn> {
if base_fee.is_empty() || base_fee == "0" {
return Err(ZeraError::Validation(
"Base fee must be provided and cannot be 0".to_string(),
));
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|error| ZeraError::Serialization(format!("Invalid system time: {error}")))?;
Ok(BaseTxn {
timestamp: Some(Timestamp {
seconds: now.as_secs() as i64,
nanos: now.subsec_nanos() as i32,
}),
fee_amount: base_fee.to_string(),
fee_id: base_fee_id.to_string(),
memo: base_memo.filter(|memo| !memo.trim().is_empty()),
..Default::default()
})
}
fn create_transfer_auth(
public_keys: Vec<PublicKey>,
signatures: Vec<Vec<u8>>,
nonces: Vec<u64>,
allowance_addresses: Vec<Vec<u8>>,
allowance_nonces: Vec<u64>,
) -> TransferAuthentication {
TransferAuthentication {
public_key: public_keys,
signature: signatures,
nonce: nonces,
allowance_address: allowance_addresses,
allowance_nonce: allowance_nonces,
}
}