1use serde::{Deserialize, Serialize};
10use stellar_xdr::{Memo, Operation, OperationBody, Transaction, TransactionEnvelope};
11
12pub const MIN_FEE_STROOPS: u32 = 100;
14pub const MAX_XDR_SIZE: usize = 100 * 1024;
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
20pub struct TransactionValidationReport {
21 pub valid: bool,
23 pub errors: Vec<ValidationError>,
25 pub warnings: Vec<ValidationWarning>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub enum ValidationError {
32 EmptyTransaction,
34 MalformedEnvelope(String),
36 EmptyOperations,
38 FeeTooLow { fee: u32, min: u32 },
40 InvalidSequence(i64),
42 MissingSourceAccount,
44 XdrTooLarge { size: usize, max: usize },
46 MissingFunctionName,
48}
49
50impl ValidationError {
51 pub fn message(&self) -> String {
53 match self {
54 ValidationError::EmptyTransaction => "transaction is empty".into(),
55 ValidationError::MalformedEnvelope(e) => format!("malformed envelope: {e}"),
56 ValidationError::EmptyOperations => "transaction has no operations".into(),
57 ValidationError::FeeTooLow { fee, min } => {
58 format!("fee {fee} stroops below minimum {min}")
59 }
60 ValidationError::InvalidSequence(s) => format!("invalid sequence number {s}"),
61 ValidationError::MissingSourceAccount => "missing source account".into(),
62 ValidationError::XdrTooLarge { size, max } => {
63 format!("XDR size {size} bytes exceeds limit {max}")
64 }
65 ValidationError::MissingFunctionName => {
66 "contract invocation missing function name".into()
67 }
68 }
69 }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74pub enum ValidationWarning {
75 UnusualMemo(String),
77 NonContractOperation(String),
79}
80
81pub fn validate(envelope: &TransactionEnvelope) -> TransactionValidationReport {
86 let mut errors = Vec::new();
87 let mut warnings = Vec::new();
88
89 match envelope {
90 TransactionEnvelope::Tx(tx) => validate_tx(&tx.tx, &mut errors, &mut warnings),
91 TransactionEnvelope::TxV0(tx) => validate_v0(&tx.tx, &mut errors, &mut warnings),
92 TransactionEnvelope::TxFeeBump(fb) => {
93 let stellar_xdr::FeeBumpTransactionInnerTx::Tx(v1) = &fb.tx.inner_tx;
95 validate_tx(&v1.tx, &mut errors, &mut warnings);
96 }
97 }
98
99 TransactionValidationReport {
100 valid: errors.is_empty(),
101 errors,
102 warnings,
103 }
104}
105
106pub fn validate_raw(raw: &[u8], envelope: &TransactionEnvelope) -> TransactionValidationReport {
108 let size = raw.len();
109 let mut report = validate(envelope);
110 if size > MAX_XDR_SIZE {
111 report.errors.push(ValidationError::XdrTooLarge {
112 size,
113 max: MAX_XDR_SIZE,
114 });
115 report.valid = false;
116 }
117 report
118}
119
120pub fn validate_base64(b64: &str) -> TransactionValidationReport {
126 let Ok(decoded) = <TransactionEnvelope as stellar_xdr::ReadXdr>::from_xdr_base64(
127 b64,
128 stellar_xdr::Limits::none(),
129 ) else {
130 return TransactionValidationReport {
131 valid: false,
132 errors: vec![ValidationError::MalformedEnvelope("bad base64".into())],
133 warnings: Vec::new(),
134 };
135 };
136 validate(&decoded)
137}
138
139fn validate_tx(
140 tx: &Transaction,
141 errors: &mut Vec<ValidationError>,
142 warnings: &mut Vec<ValidationWarning>,
143) {
144 if tx.operations.is_empty() {
145 errors.push(ValidationError::EmptyOperations);
146 }
147
148 if tx.fee < MIN_FEE_STROOPS {
149 errors.push(ValidationError::FeeTooLow {
150 fee: tx.fee,
151 min: MIN_FEE_STROOPS,
152 });
153 }
154
155 if tx.seq_num.0 <= 0 {
156 errors.push(ValidationError::InvalidSequence(tx.seq_num.0));
157 }
158
159 match &tx.memo {
160 Memo::Hash(_) | Memo::Return(_) => {
161 warnings.push(ValidationWarning::UnusualMemo(format!("{:?}", tx.memo)));
162 }
163 _ => {}
164 }
165
166 for op in tx.operations.iter() {
167 validate_operation(op, errors, warnings);
168 }
169}
170
171fn validate_v0(
172 tx: &stellar_xdr::TransactionV0,
173 errors: &mut Vec<ValidationError>,
174 warnings: &mut Vec<ValidationWarning>,
175) {
176 if tx.operations.is_empty() {
177 errors.push(ValidationError::EmptyOperations);
178 }
179 if tx.fee < MIN_FEE_STROOPS {
180 errors.push(ValidationError::FeeTooLow {
181 fee: tx.fee,
182 min: MIN_FEE_STROOPS,
183 });
184 }
185 if tx.seq_num.0 <= 0 {
186 errors.push(ValidationError::InvalidSequence(tx.seq_num.0));
187 }
188 match &tx.memo {
189 Memo::Hash(_) | Memo::Return(_) => {
190 warnings.push(ValidationWarning::UnusualMemo(format!("{:?}", tx.memo)));
191 }
192 _ => {}
193 }
194 for op in tx.operations.iter() {
195 validate_operation(op, errors, warnings);
196 }
197}
198
199fn validate_operation(
200 op: &Operation,
201 errors: &mut Vec<ValidationError>,
202 warnings: &mut Vec<ValidationWarning>,
203) {
204 match &op.body {
205 OperationBody::InvokeHostFunction(hf) => {
206 use stellar_xdr::HostFunction;
207 if let HostFunction::InvokeContract(args) = &hf.host_function {
208 if args.function_name.to_utf8_string_lossy().is_empty() {
209 errors.push(ValidationError::MissingFunctionName);
210 }
211 }
212 }
213 OperationBody::BumpSequence(_)
216 | OperationBody::ManageData(_)
217 | OperationBody::SetOptions(_)
218 | OperationBody::ChangeTrust(_)
219 | OperationBody::Payment(_)
220 | OperationBody::CreateAccount(_) => {
221 warnings.push(ValidationWarning::NonContractOperation(
222 "classic operation(s) present".into(),
223 ));
224 }
225 _ => {}
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use stellar_xdr::{
233 Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo, MuxedAccount,
234 Operation, OperationBody, ScAddress, ScSymbol, SequenceNumber, Transaction,
235 TransactionEnvelope, TransactionV1Envelope, Uint256, VecM, WriteXdr,
236 };
237
238 fn dummy_source() -> MuxedAccount {
239 MuxedAccount::Ed25519(Uint256([1u8; 32]))
240 }
241
242 fn make_envelope(fee: u32, seq: i64, ops: Vec<Operation>, memo: Memo) -> TransactionEnvelope {
243 let tx = Transaction {
244 source_account: dummy_source(),
245 fee,
246 seq_num: SequenceNumber(seq),
247 cond: stellar_xdr::Preconditions::None,
248 memo,
249 operations: ops.try_into().unwrap(),
250 ext: stellar_xdr::TransactionExt::V0,
251 };
252 let env_v1 = TransactionV1Envelope {
253 tx,
254 signatures: VecM::default(),
255 };
256 TransactionEnvelope::Tx(env_v1)
257 }
258
259 fn make_invoke_contract_op(function: &str) -> Operation {
260 let contract_id = stellar_xdr::ContractId(Hash([2u8; 32]));
261 Operation {
262 source_account: None,
263 body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
264 host_function: HostFunction::InvokeContract(InvokeContractArgs {
265 contract_address: ScAddress::Contract(contract_id),
266 function_name: ScSymbol(function.as_bytes().try_into().unwrap()),
267 args: VecM::default(),
268 }),
269 auth: VecM::default(),
270 }),
271 }
272 }
273
274 #[test]
275 fn valid_tx() {
276 let env = make_envelope(200, 10, vec![make_invoke_contract_op("hello")], Memo::None);
277 let report = validate(&env);
278 assert!(report.valid);
279 assert!(report.errors.is_empty());
280 }
281
282 #[test]
283 fn empty_tx() {
284 let env = make_envelope(200, 10, vec![], Memo::None);
285 let report = validate(&env);
286 assert!(!report.valid);
287 assert!(matches!(
288 report.errors.first(),
289 Some(ValidationError::EmptyOperations)
290 ));
291 }
292
293 #[test]
294 fn invalid_fee() {
295 let env = make_envelope(50, 10, vec![make_invoke_contract_op("hello")], Memo::None);
296 let report = validate(&env);
297 assert!(!report.valid);
298 assert!(matches!(
299 report.errors.first(),
300 Some(ValidationError::FeeTooLow { fee: 50, min: 100 })
301 ));
302 }
303
304 #[test]
305 fn invalid_sequence() {
306 let env = make_envelope(200, 0, vec![make_invoke_contract_op("hello")], Memo::None);
307 let report = validate(&env);
308 assert!(!report.valid);
309 assert!(matches!(
310 report.errors.first(),
311 Some(ValidationError::InvalidSequence(0))
312 ));
313 }
314
315 #[test]
316 fn malformed_envelope() {
317 let report = validate_base64("not base64 at all");
318 assert!(!report.valid);
319 assert!(matches!(
320 report.errors.first(),
321 Some(ValidationError::MalformedEnvelope(_))
322 ));
323 }
324
325 #[test]
326 fn oversize_xdr() {
327 let env = make_envelope(200, 10, vec![make_invoke_contract_op("hello")], Memo::None);
328 let mut buf = Vec::new();
329 use stellar_xdr::Limited;
330 let mut l = Limited::new(&mut buf, stellar_xdr::Limits::none());
331 env.write_xdr(&mut l).unwrap();
332 let oversized = vec![0u8; MAX_XDR_SIZE + 1];
333 let report = validate_raw(&oversized, &env);
334 assert!(!report.valid);
335 assert!(matches!(
336 report.errors.first(),
337 Some(ValidationError::XdrTooLarge { .. })
338 ));
339 }
340
341 #[test]
342 fn missing_function_name() {
343 let op = Operation {
344 source_account: None,
345 body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
346 host_function: HostFunction::InvokeContract(InvokeContractArgs {
347 contract_address: ScAddress::Contract(stellar_xdr::ContractId(Hash([3u8; 32]))),
348 function_name: ScSymbol("".as_bytes().try_into().unwrap()),
349 args: VecM::default(),
350 }),
351 auth: VecM::default(),
352 }),
353 };
354 let env = make_envelope(200, 10, vec![op], Memo::None);
355 let report = validate(&env);
356 assert!(!report.valid);
357 assert!(matches!(
358 report.errors.first(),
359 Some(ValidationError::MissingFunctionName)
360 ));
361 }
362
363 #[test]
364 fn classic_operation_warning() {
365 let op = Operation {
366 source_account: None,
367 body: OperationBody::ManageData(stellar_xdr::ManageDataOp {
368 data_name: stellar_xdr::StringM::try_from("foo".as_bytes())
369 .unwrap()
370 .into(),
371 data_value: None,
372 }),
373 };
374 let env = make_envelope(200, 10, vec![op], Memo::None);
375 let report = validate(&env);
376 assert!(report.valid); assert!(report
378 .warnings
379 .iter()
380 .any(|w| matches!(w, ValidationWarning::NonContractOperation(_))));
381 }
382}