use std::collections::{BTreeSet, HashMap};
use solana_transaction_status::{
EncodedConfirmedTransactionWithStatusMeta, EncodedTransaction, UiMessage,
UiTransactionTokenBalance, option_serializer::OptionSerializer,
};
use crate::output::{SolChange, TokenChange, Transaction};
fn token_index_map(
balances: &[UiTransactionTokenBalance],
) -> HashMap<u8, (u64, String, String, u8)> {
balances
.iter()
.map(|t| {
let amount = t.ui_token_amount.amount.parse::<u64>().unwrap_or(0);
let owner = match &t.owner {
OptionSerializer::Some(o) => o.clone(),
_ => String::new(),
};
(
t.account_index,
(amount, t.mint.clone(), owner, t.ui_token_amount.decimals),
)
})
.collect()
}
pub fn transaction_from_encoded(
confirmed: EncodedConfirmedTransactionWithStatusMeta,
signature: &str,
slot: u64,
) -> Option<Transaction> {
let block_time = confirmed.block_time.map(|t| t as u64);
let inner = confirmed.transaction;
let meta = inner.meta.as_ref()?;
let error = meta.err.as_ref().map(|e| e.to_string());
let static_keys: Vec<String> = match &inner.transaction {
EncodedTransaction::Json(ui_tx) => match &ui_tx.message {
UiMessage::Raw(raw) => raw.account_keys.clone(),
UiMessage::Parsed(parsed) => parsed
.account_keys
.iter()
.map(|k| k.pubkey.clone())
.collect(),
},
_ => return None,
};
let mut keys = static_keys;
if let OptionSerializer::Some(addresses) = &meta.loaded_addresses {
keys.extend(addresses.writable.iter().cloned());
keys.extend(addresses.readonly.iter().cloned());
}
let sol_changes: Vec<SolChange> = keys
.iter()
.enumerate()
.filter_map(|(i, pubkey)| {
let pre = *meta.pre_balances.get(i)?;
let post = *meta.post_balances.get(i)?;
if pre == post {
return None;
}
Some(SolChange {
pubkey: pubkey.clone(),
pre_lamports: pre,
post_lamports: post,
})
})
.collect();
let empty = Vec::new();
let pre_tokens = match &meta.pre_token_balances {
OptionSerializer::Some(v) => v,
_ => &empty,
};
let post_tokens = match &meta.post_token_balances {
OptionSerializer::Some(v) => v,
_ => &empty,
};
let pre_map = token_index_map(pre_tokens);
let post_map = token_index_map(post_tokens);
let mut all_indices: BTreeSet<u8> = BTreeSet::new();
all_indices.extend(pre_map.keys().copied());
all_indices.extend(post_map.keys().copied());
let token_changes: Vec<TokenChange> = all_indices
.into_iter()
.filter_map(|idx| {
let pubkey = keys.get(idx as usize)?.clone();
let (pre_amount, mint, owner, decimals) = if let Some(info) = pre_map.get(&idx) {
(info.0, info.1.clone(), info.2.clone(), info.3)
} else {
let info = post_map.get(&idx)?;
(0, info.1.clone(), info.2.clone(), info.3)
};
let post_amount = post_map.get(&idx).map(|i| i.0).unwrap_or(0);
if pre_amount == post_amount {
return None;
}
Some(TokenChange {
pubkey,
mint,
owner,
pre_amount,
post_amount,
decimals,
})
})
.collect();
Some(Transaction {
slot,
timestamp: block_time,
signature: signature.to_string(),
success: error.is_none(),
error,
sol_changes,
token_changes,
logs: Vec::new(),
account_diffs: Vec::new(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn decode(transaction: serde_json::Value, meta: serde_json::Value) -> Option<Transaction> {
let confirmed: EncodedConfirmedTransactionWithStatusMeta =
serde_json::from_value(serde_json::json!({
"slot": 42,
"blockTime": 1_700_000_000,
"transaction": transaction,
"meta": meta,
}))
.unwrap();
transaction_from_encoded(confirmed, "sig", 42)
}
fn raw_transaction(account_keys: &[&str]) -> serde_json::Value {
serde_json::json!({
"signatures": ["sig"],
"message": {
"header": {
"numRequiredSignatures": 1,
"numReadonlySignedAccounts": 0,
"numReadonlyUnsignedAccounts": 0,
},
"accountKeys": account_keys,
"recentBlockhash": "hash",
"instructions": [],
},
})
}
fn token_balance(account_index: u8, amount: &str) -> serde_json::Value {
serde_json::json!({
"accountIndex": account_index,
"mint": "mint-a",
"owner": "owner-1",
"uiTokenAmount": {
"uiAmount": null,
"decimals": 6,
"amount": amount,
"uiAmountString": amount,
},
})
}
#[test]
fn missing_meta_is_none() {
let result = decode(raw_transaction(&["payer"]), serde_json::Value::Null);
assert!(result.is_none());
}
#[test]
fn binary_transaction_is_none() {
let meta = serde_json::json!({
"err": null, "status": {"Ok": null}, "fee": 0,
"preBalances": [100], "postBalances": [100],
});
assert!(decode(serde_json::json!("3qd..."), meta).is_none());
}
#[test]
fn loaded_addresses_extend_static_keys_and_equal_balances_are_skipped() {
let meta = serde_json::json!({
"err": null, "status": {"Ok": null}, "fee": 0,
"preBalances": [100, 50, 10, 7],
"postBalances": [90, 50, 25, 7],
"loadedAddresses": {"writable": ["loaded-w"], "readonly": ["loaded-r"]},
});
let tx = decode(raw_transaction(&["payer", "static-1"]), meta).unwrap();
assert!(tx.success);
assert_eq!(tx.timestamp, Some(1_700_000_000));
assert_eq!(
tx.sol_changes
.iter()
.map(|c| (c.pubkey.as_str(), c.pre_lamports, c.post_lamports))
.collect::<Vec<_>>(),
[("payer", 100, 90), ("loaded-w", 10, 25)]
);
}
#[test]
fn post_only_token_balance_gets_zero_pre_amount() {
let meta = serde_json::json!({
"err": null, "status": {"Ok": null}, "fee": 0,
"preBalances": [100, 10], "postBalances": [100, 10],
"postTokenBalances": [token_balance(1, "500")],
});
let tx = decode(raw_transaction(&["payer", "token-acct"]), meta).unwrap();
assert_eq!(tx.token_changes.len(), 1);
let change = &tx.token_changes[0];
assert_eq!(change.pubkey, "token-acct");
assert_eq!(change.mint, "mint-a");
assert_eq!(change.owner, "owner-1");
assert_eq!((change.pre_amount, change.post_amount), (0, 500));
assert_eq!(change.decimals, 6);
}
#[test]
fn equal_token_amounts_are_skipped() {
let meta = serde_json::json!({
"err": null, "status": {"Ok": null}, "fee": 0,
"preBalances": [100, 10], "postBalances": [100, 10],
"preTokenBalances": [token_balance(1, "500")],
"postTokenBalances": [token_balance(1, "500")],
});
let tx = decode(raw_transaction(&["payer", "token-acct"]), meta).unwrap();
assert!(tx.token_changes.is_empty());
}
}