Skip to main content

sdkt_core/
validation.rs

1//! Offline, pure transaction validation (no RPC).
2//!
3//! [`validate`] inspects a decoded [`stellar_xdr::TransactionEnvelope`] and
4//! produces a [`TransactionValidationReport`] containing hard errors and soft
5//! warnings. Validation never touches the network: it only inspects the XDR
6//! structure the caller already holds. Use it as a pre-flight check before
7//! simulation / submission.
8
9use serde::{Deserialize, Serialize};
10use stellar_xdr::{Memo, Operation, OperationBody, Transaction, TransactionEnvelope};
11
12/// Default minimum base fee (stroops) used for hard validation.
13pub const MIN_FEE_STROOPS: u32 = 100;
14/// Conservative hard cap on serialized envelope size in bytes. Stellar's
15/// current network limit is 100 KiB; this allows checking oversize locally.
16pub const MAX_XDR_SIZE: usize = 100 * 1024;
17
18/// The result of running [`validate`].
19#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
20pub struct TransactionValidationReport {
21    /// True when there are no errors (warnings are allowed).
22    pub valid: bool,
23    /// Hard failures that must be fixed before submission.
24    pub errors: Vec<ValidationError>,
25    /// Non-blocking observations.
26    pub warnings: Vec<ValidationWarning>,
27}
28
29/// A hard validation failure.
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub enum ValidationError {
32    /// An empty base64/raw payload was supplied.
33    EmptyTransaction,
34    /// The payload could not be decoded as a `TransactionEnvelope`.
35    MalformedEnvelope(String),
36    /// The envelope has zero operations.
37    EmptyOperations,
38    /// The base fee is below the network minimum.
39    FeeTooLow { fee: u32, min: u32 },
40    /// The sequence number is not a positive value.
41    InvalidSequence(i64),
42    /// The transaction has no source account (cannot be expressed).
43    MissingSourceAccount,
44    /// The serialized envelope exceeds [`MAX_XDR_SIZE`].
45    XdrTooLarge { size: usize, max: usize },
46    /// An `InvokeHostFunction` references a contract but the function name is empty.
47    MissingFunctionName,
48}
49
50impl ValidationError {
51    /// Human-readable message (used by CLI pretty output).
52    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/// A soft, non-blocking observation.
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74pub enum ValidationWarning {
75    /// Memo is of kind `Hash` or `Return` — unusual but valid.
76    UnusualMemo(String),
77    /// Operation type is not commonly seen on Soroban contracts.
78    NonContractOperation(String),
79}
80
81/// Validate a decoded transaction envelope.
82///
83/// Pure: performs no I/O and no RPC. Supply the envelope you already decoded
84/// (e.g. via base64 → [`stellar_xdr::TransactionEnvelope::read_xdr`]).
85pub 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            // Fee-bump envelopes wrap a nested transaction; validate the inner one.
94            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
106/// Validate a `TransactionEnvelope` supplied as raw bytes (already parsed).
107pub 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
120/// Validate a base64 `TransactionEnvelope`.
121///
122/// This both decodes the payload and validates it, returning a report. The
123/// `base64` crate is re-exported through the `stellar_xdr` feature set; we
124/// decode using `stellar_xdr::ReadXdr` from the base64 string.
125pub 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        // Soroban allows only a small set of operations; flag the rest as a
214        // warning rather than an error (transactions can mix classic + Soroban ops).
215        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); // warnings don't invalidate
377        assert!(report
378            .warnings
379            .iter()
380            .any(|w| matches!(w, ValidationWarning::NonContractOperation(_))));
381    }
382}