From 2c613900ff11b2437a038c49d44dfd52f59280f6 Mon Sep 17 00:00:00 2001 From: nazteeemba Date: Sun, 19 Jul 2026 14:24:59 +0100 Subject: [PATCH 1/4] Add Soroban fee analyzer --- crates/core/src/decode/context.rs | 82 +------- crates/core/src/decode/fee_analyzer.rs | 269 +++++++++++++++++++++++++ crates/core/src/decode/mod.rs | 4 + 3 files changed, 275 insertions(+), 80 deletions(-) create mode 100644 crates/core/src/decode/fee_analyzer.rs diff --git a/crates/core/src/decode/context.rs b/crates/core/src/decode/context.rs index 27ab9f65..5aa244a1 100644 --- a/crates/core/src/decode/context.rs +++ b/crates/core/src/decode/context.rs @@ -1,11 +1,11 @@ use crate::decode::auth::{AuthChain, AuthCredential}; use crate::decode::auth_signature::decode_auth_entry_signatures; +use crate::decode::fee_analyzer::analyze_fee_breakdown; use crate::error::GratResult; use crate::types::report::{ AuthEntryInfo, DiagnosticReport, FeeBreakdown, ResourceSummary, TransactionContext, }; use crate::xdr::codec::XdrCodec; -use stellar_xdr::curr::{TransactionEnvelope, TransactionMeta, TransactionResult}; pub fn enrich_report(report: &mut DiagnosticReport, tx_data: &serde_json::Value) -> GratResult<()> { let tx_hash = tx_data @@ -61,85 +61,7 @@ fn extract_return_value(tx_data: &serde_json::Value) -> Option { } fn extract_fee_breakdown(tx_data: &serde_json::Value) -> FeeBreakdown { - let mut total_fee = 0; - if let Some(result_xdr_b64) = tx_data.get("resultXdr").and_then(|v| v.as_str()) { - if let Ok(tx_result) = TransactionResult::from_xdr_base64(result_xdr_b64) { - total_fee = tx_result.fee_charged; - } - } - - let mut bid_fee = None; - if let Some(envelope_xdr_b64) = tx_data.get("envelopeXdr").and_then(|v| v.as_str()) { - if let Ok(tx_envelope) = TransactionEnvelope::from_xdr_base64(envelope_xdr_b64) { - match tx_envelope { - TransactionEnvelope::Tx(v1) => { - bid_fee = Some(i64::from(v1.tx.fee)); - } - TransactionEnvelope::TxFeeBump(fee_bump) => { - bid_fee = Some(fee_bump.tx.fee); - } - TransactionEnvelope::TxV0(v0) => { - bid_fee = Some(i64::from(v0.tx.fee)); - } - } - } - } - - let mut non_refundable_fee = 0; - let mut refundable_fee = 0; - let mut rent_fee = 0; - let mut has_soroban_meta = false; - - if let Some(resource_fee_obj) = tx_data.get("resourceFee").and_then(|v| v.as_object()) { - non_refundable_fee = resource_fee_obj - .get("totalNonRefundableResourceFeeCharged") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - refundable_fee = resource_fee_obj - .get("totalRefundableResourceFeeCharged") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - rent_fee = resource_fee_obj - .get("rentFeeCharged") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - has_soroban_meta = true; - } else if let Some(meta_xdr_b64) = tx_data.get("resultMetaXdr").and_then(|v| v.as_str()) { - if let Ok(TransactionMeta::V3(v3)) = TransactionMeta::from_xdr_base64(meta_xdr_b64) { - if let Some(soroban_meta) = v3.soroban_meta { - match soroban_meta.ext { - stellar_xdr::curr::SorobanTransactionMetaExt::V0 => {} - stellar_xdr::curr::SorobanTransactionMetaExt::V1(v1) => { - non_refundable_fee = v1.total_non_refundable_resource_fee_charged; - refundable_fee = v1.total_refundable_resource_fee_charged; - rent_fee = v1.rent_fee_charged; - has_soroban_meta = true; - } - } - } - } - } - - let resource_fee = if has_soroban_meta { - non_refundable_fee + refundable_fee + rent_fee - } else { - 0 - }; - - let inclusion_fee = tx_data - .get("inclusionFee") - .and_then(serde_json::Value::as_i64) - .unwrap_or(total_fee - resource_fee); - - FeeBreakdown { - total_charged_fee: total_fee, - inclusion_fee, - resource_fee, - refundable_resource_fee: refundable_fee, - refundable_fee: refundable_fee + rent_fee, - non_refundable_fee, - bid_fee, - } + analyze_fee_breakdown(tx_data) } fn extract_resource_summary(tx_data: &serde_json::Value) -> ResourceSummary { diff --git a/crates/core/src/decode/fee_analyzer.rs b/crates/core/src/decode/fee_analyzer.rs new file mode 100644 index 00000000..bef5bc2c --- /dev/null +++ b/crates/core/src/decode/fee_analyzer.rs @@ -0,0 +1,269 @@ +use crate::error::GratResult; +use crate::types::report::FeeBreakdown; +use stellar_xdr::curr::{TransactionEnvelope, TransactionMeta, TransactionResult}; + +pub fn analyze_fee_breakdown(tx_data: &serde_json::Value) -> FeeBreakdown { + let total_fee = tx_data + .get("resultXdr") + .and_then(|v| v.as_str()) + .and_then(parse_total_fee) + .unwrap_or(0); + + let bid_fee = tx_data + .get("envelopeXdr") + .and_then(|v| v.as_str()) + .and_then(parse_bid_fee) + .or_else(|| tx_data.get("feeBid")?.as_i64()); + + let (non_refundable_fee, refundable_resource_fee, rent_fee, has_soroban_resource_fee) = tx_data + .get("resourceFee") + .and_then(|v| v.as_object()) + .map(parse_resource_fee_object) + .unwrap_or_else(|| parse_resource_fee_from_meta(tx_data)); + + let resource_fee = if has_soroban_resource_fee { + non_refundable_fee + refundable_resource_fee + rent_fee + } else { + 0 + }; + + let inclusion_fee = tx_data + .get("inclusionFee") + .and_then(|v| v.as_i64()) + .unwrap_or_else(|| { + if resource_fee > 0 { + total_fee.saturating_sub(resource_fee) + } else { + total_fee + } + }); + + FeeBreakdown { + total_charged_fee: total_fee, + inclusion_fee, + resource_fee, + refundable_resource_fee, + refundable_fee: refundable_resource_fee + rent_fee, + non_refundable_fee, + bid_fee, + } +} + +pub fn inject_fee_metadata(tx_data: &mut serde_json::Value) -> GratResult<()> { + let breakdown = analyze_fee_breakdown(tx_data); + + tx_data["inclusionFee"] = serde_json::json!(breakdown.inclusion_fee); + if breakdown.resource_fee > 0 { + tx_data["resourceFee"] = serde_json::json!({ + "totalNonRefundableResourceFeeCharged": breakdown.non_refundable_fee, + "totalRefundableResourceFeeCharged": breakdown.refundable_resource_fee, + "rentFeeCharged": breakdown.refundable_fee.saturating_sub(breakdown.refundable_resource_fee), + }); + } + + Ok(()) +} + +fn parse_total_fee(result_xdr_b64: &str) -> Option { + TransactionResult::from_xdr_base64(result_xdr_b64) + .ok() + .map(|result| result.fee_charged) +} + +fn parse_bid_fee(envelope_xdr_b64: &str) -> Option { + let tx_envelope = TransactionEnvelope::from_xdr_base64(envelope_xdr_b64).ok()?; + Some(match tx_envelope { + TransactionEnvelope::Tx(v1) => i64::from(v1.tx.fee), + TransactionEnvelope::TxFeeBump(fee_bump) => fee_bump.tx.fee, + TransactionEnvelope::TxV0(v0) => i64::from(v0.tx.fee), + }) +} + +fn parse_resource_fee_object(resource_fee_obj: &serde_json::Map) -> (i64, i64, i64, bool) { + ( + resource_fee_obj + .get("totalNonRefundableResourceFeeCharged") + .and_then(|v| v.as_i64()) + .unwrap_or(0), + resource_fee_obj + .get("totalRefundableResourceFeeCharged") + .and_then(|v| v.as_i64()) + .unwrap_or(0), + resource_fee_obj + .get("rentFeeCharged") + .and_then(|v| v.as_i64()) + .unwrap_or(0), + true, + ) +} + +fn parse_resource_fee_from_meta(tx_data: &serde_json::Value) -> (i64, i64, i64, bool) { + let Some(meta_xdr_b64) = tx_data.get("resultMetaXdr").and_then(|v| v.as_str()) else { + return (0, 0, 0, false); + }; + + let Ok(TransactionMeta::V3(v3)) = TransactionMeta::from_xdr_base64(meta_xdr_b64) else { + return (0, 0, 0, false); + }; + + let Some(soroban_meta) = v3.soroban_meta else { + return (0, 0, 0, false); + }; + + match soroban_meta.ext { + stellar_xdr::curr::SorobanTransactionMetaExt::V0 => (0, 0, 0, false), + stellar_xdr::curr::SorobanTransactionMetaExt::V1(v1) => ( + v1.total_non_refundable_resource_fee_charged, + v1.total_refundable_resource_fee_charged, + v1.rent_fee_charged, + true, + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::xdr::codec::XdrCodec; + use stellar_xdr::curr::{ + ExtensionPoint, Memo, MuxedAccount, Preconditions, SequenceNumber, SorobanTransactionMeta, + SorobanTransactionMetaExt, SorobanTransactionMetaExtV1, Transaction, TransactionEnvelope, + TransactionExt, TransactionMeta, TransactionMetaV3, TransactionResult, + TransactionResultResult, TransactionV1Envelope, Uint256, + }; + + #[test] + fn analyzes_non_soroban_fee_breakdown() { + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0; 32])), + fee: 150, + seq_num: SequenceNumber(1), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![].try_into().unwrap(), + ext: TransactionExt::V0, + }; + let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: vec![].try_into().unwrap(), + }); + let envelope_xdr = envelope.to_xdr_base64().unwrap(); + + let result = TransactionResult { + fee_charged: 120, + result: TransactionResultResult::TxSuccess(vec![].try_into().unwrap()), + ext: stellar_xdr::curr::TransactionResultExt::V0, + }; + let result_xdr = result.to_xdr_base64().unwrap(); + + let tx_data = serde_json::json!({ + "envelopeXdr": envelope_xdr, + "resultXdr": result_xdr, + }); + + let breakdown = analyze_fee_breakdown(&tx_data); + assert_eq!(breakdown.total_charged_fee, 120); + assert_eq!(breakdown.bid_fee, Some(150)); + assert_eq!(breakdown.inclusion_fee, 120); + assert_eq!(breakdown.resource_fee, 0); + assert_eq!(breakdown.refundable_resource_fee, 0); + assert_eq!(breakdown.refundable_fee, 0); + assert_eq!(breakdown.non_refundable_fee, 0); + } + + #[test] + fn analyzes_soroban_fee_breakdown() { + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0; 32])), + fee: 500, + seq_num: SequenceNumber(1), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![].try_into().unwrap(), + ext: TransactionExt::V0, + }; + let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: vec![].try_into().unwrap(), + }); + let envelope_xdr = envelope.to_xdr_base64().unwrap(); + + let result = TransactionResult { + fee_charged: 450, + result: TransactionResultResult::TxSuccess(vec![].try_into().unwrap()), + ext: stellar_xdr::curr::TransactionResultExt::V0, + }; + let result_xdr = result.to_xdr_base64().unwrap(); + + let meta = TransactionMeta::V3(TransactionMetaV3 { + ext: ExtensionPoint::V0, + tx_changes_before: vec![].try_into().unwrap(), + operations: vec![].try_into().unwrap(), + tx_changes_after: vec![].try_into().unwrap(), + soroban_meta: Some(SorobanTransactionMeta { + ext: SorobanTransactionMetaExt::V1(SorobanTransactionMetaExtV1 { + ext: ExtensionPoint::V0, + total_non_refundable_resource_fee_charged: 100, + total_refundable_resource_fee_charged: 200, + rent_fee_charged: 50, + }), + events: vec![].try_into().unwrap(), + return_value: stellar_xdr::curr::ScVal::Void, + diagnostic_events: vec![].try_into().unwrap(), + }), + }); + let meta_xdr = meta.to_xdr_base64().unwrap(); + + let tx_data = serde_json::json!({ + "envelopeXdr": envelope_xdr, + "resultXdr": result_xdr, + "resultMetaXdr": meta_xdr, + }); + + let breakdown = analyze_fee_breakdown(&tx_data); + assert_eq!(breakdown.total_charged_fee, 450); + assert_eq!(breakdown.bid_fee, Some(500)); + assert_eq!(breakdown.resource_fee, 350); + assert_eq!(breakdown.inclusion_fee, 100); + assert_eq!(breakdown.refundable_resource_fee, 200); + assert_eq!(breakdown.refundable_fee, 250); + assert_eq!(breakdown.non_refundable_fee, 100); + } + + #[test] + fn handles_insufficient_fee_surges() { + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0; 32])), + fee: 400, + seq_num: SequenceNumber(1), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![].try_into().unwrap(), + ext: TransactionExt::V0, + }; + let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: vec![].try_into().unwrap(), + }); + let envelope_xdr = envelope.to_xdr_base64().unwrap(); + + let result = TransactionResult { + fee_charged: 600, + result: TransactionResultResult::TxSuccess(vec![].try_into().unwrap()), + ext: stellar_xdr::curr::TransactionResultExt::V0, + }; + let result_xdr = result.to_xdr_base64().unwrap(); + + let tx_data = serde_json::json!({ + "envelopeXdr": envelope_xdr, + "resultXdr": result_xdr, + }); + + let breakdown = analyze_fee_breakdown(&tx_data); + assert_eq!(breakdown.total_charged_fee, 600); + assert_eq!(breakdown.bid_fee, Some(400)); + assert_eq!(breakdown.inclusion_fee, 600); + assert_eq!(breakdown.resource_fee, 0); + assert_eq!(breakdown.refundable_fee, 0); + } +} diff --git a/crates/core/src/decode/mod.rs b/crates/core/src/decode/mod.rs index f34d32ad..de1a41a0 100644 --- a/crates/core/src/decode/mod.rs +++ b/crates/core/src/decode/mod.rs @@ -6,6 +6,7 @@ pub mod contract_error; pub mod cross_contract; pub mod decode_context; pub mod diagnostic; +pub mod fee_analyzer; pub mod host_error; pub mod mappings; pub mod report; @@ -20,6 +21,7 @@ pub use walker::{ walk_diagnostic_events, DiagnosticEventKind, DiagnosticEventWalker, StructuredDiagnosticEvent, }; +use crate::decode::fee_analyzer::inject_fee_metadata; use crate::error::{GratError, GratResult}; use crate::types::report::DiagnosticReport; use crate::xdr::codec::XdrCodec; @@ -104,6 +106,8 @@ fn parse_v3_metadata(tx_data: &mut serde_json::Value) -> GratResult<()> { tx_data["inclusionFee"] = serde_json::json!(inclusion_fee); } + inject_fee_metadata(tx_data)?; + Ok(()) } From fbb0e89e8a177fbf1831c9cb407244c4691b9df4 Mon Sep 17 00:00:00 2001 From: nazteeemba Date: Sun, 19 Jul 2026 14:29:47 +0100 Subject: [PATCH 2/4] Add Soroban fee analysis --- testlist.txt | Bin 0 -> 15952 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 testlist.txt diff --git a/testlist.txt b/testlist.txt new file mode 100644 index 0000000000000000000000000000000000000000..222f57ffca1fc97e7f74480c63b2b7110d1fa20b GIT binary patch literal 15952 zcmeI3eQy&-5XSfKO8pL2Bm|{(ATAA*QY2mj5^X5Z7O7MrIUm8nu~VOuK;J%k+vm6A z_1@W+@M02OrPb-;+qaq7+1c57ZZ7}+letYda@%g`TJEZDyKbqx>-JpF^>q)_-co6# z+RU}pvZ-H69n~MW6}Rjz>R!>`%kES6iM!#(YO%4o=&q=gxtE?7CHj2s>mv8D%U$8e zwZ*F?o+I@ii(e*ABek~l-1bzrCeN|PMxJ-q)eiQKu zHyO3anzsCk8P}`gGuC>TU3@0$zqnuB7y5l9p1WR>+})AJGnMz$x94>|JIA|jUu)Ud z?0(SGreD_}_Z`v4zDAmPSCnmzSVK?tmiyJ)^>#Q=d+cpIBB#1J|0Zr|3ww&Tdud|1 zHpjYgg+`3TEq5Cwi;NUH+6`Zq^i)UVcgfsyI~C3y_oW{Nb(=G@wY~FdqUDx5)YDiy zsO6rk*S@!F_CjA0Jy-1uX^`C-Ry~*g8Nt3Ada0pmJC8a3IhFKVl?D@y&}V6m{*te4 zi$`Bv4*a@UU69gB7i5R)?t$(#-Bx!?YxhjokCK=f7QS>(c0uiJ)fMiQj_`Q4yMdnY)Kc}zQhw6==xI7bX#k(sd?vG-&NhY%4qzG`enMeeD9#q zOf(K<-@DTECErrWK6lk?D9VLA#BegNYu@)E;ZUtRqMJ!7*1=Yx&@(=iHNe)o@=T_Y zYohZ;H*1X@UiW;^WL1_}^IFob%Ia1n6TT52j6GocLp>EL@9OV?TF{G)yx{)O6B0rh zKiE~j^V+G_z9+p5)Ru|zzMcZba-U4|*;YFqrCcBS8utxPaiDc9_lY{)5NA9C{I=CM zuk=Gglg|2~Ep%C?#mP}vTbYirT z_V3X93gQz^_?Aj9^?Rr%(|wF{=x-}M&-W-CVibk8@h8NP7ULj0QSv}F zXOHxrXJyqk0-5(^l|)|QY==gWwks<|C!AQ+zVX(IW@oP?t?jyI>URFLYo?s_r*AW5 zt&0SjDQSIVauTxQ%edd}r>apQo;6?^{OP%AjEHY5(G%`9w7ujtg11Vz_gU#| z08PKI*NGVg6Si(*qG zATR!InOSewCYyQbQbjJkngGb^fGIDE0XN zQ222F6EJGm+g;3|&gW10e~06|S;D9(e`>y;(`yzLCybgfYQm@qqgwQEnqRu7{OLTN aCJN@RW-IHgle+l-WPCYX7i*jZLj4b6a8H*2 literal 0 HcmV?d00001 From 8f4f9a697fd96e7fb325ff3fe10d49b9982b3526 Mon Sep 17 00:00:00 2001 From: nazteeemba Date: Sun, 19 Jul 2026 14:40:38 +0100 Subject: [PATCH 3/4] Add Soroban fee analysis From 3d11fbac615b02d4c936b72cdd23cda59397e252 Mon Sep 17 00:00:00 2001 From: nazteeemba Date: Sun, 19 Jul 2026 15:31:05 +0100 Subject: [PATCH 4/4] Format Soroban fee analyzer --- crates/core/src/decode/fee_analyzer.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/core/src/decode/fee_analyzer.rs b/crates/core/src/decode/fee_analyzer.rs index bef5bc2c..d3c23807 100644 --- a/crates/core/src/decode/fee_analyzer.rs +++ b/crates/core/src/decode/fee_analyzer.rs @@ -79,7 +79,9 @@ fn parse_bid_fee(envelope_xdr_b64: &str) -> Option { }) } -fn parse_resource_fee_object(resource_fee_obj: &serde_json::Map) -> (i64, i64, i64, bool) { +fn parse_resource_fee_object( + resource_fee_obj: &serde_json::Map, +) -> (i64, i64, i64, bool) { ( resource_fee_obj .get("totalNonRefundableResourceFeeCharged")