openzeppelin_relayer/models/transaction/stellar/
operation.rs

1//! Operation types and conversions for Stellar transactions
2
3use crate::models::transaction::stellar::asset::AssetSpec;
4use crate::models::transaction::stellar::host_function::{ContractSource, WasmSource};
5use crate::models::SignerError;
6use crate::utils::{deserialize_i64, serialize_i64};
7use serde::{Deserialize, Serialize};
8use soroban_rs::xdr::{
9    HostFunction, InvokeHostFunctionOp, MuxedAccount as XdrMuxedAccount, MuxedAccountMed25519,
10    Operation, OperationBody, PaymentOp, SorobanAuthorizationEntry, SorobanAuthorizedFunction,
11    SorobanAuthorizedInvocation, SorobanCredentials, Uint256, VecM,
12};
13use std::convert::TryFrom;
14use stellar_strkey::{ed25519::MuxedAccount, ed25519::PublicKey};
15use utoipa::ToSchema;
16
17/// Authorization specification for Soroban operations
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
19#[serde(tag = "type", rename_all = "snake_case")]
20pub enum AuthSpec {
21    /// No authorization required
22    None,
23
24    /// Use the transaction source account for authorization
25    SourceAccount,
26
27    /// Use specific addresses for authorization
28    Addresses { signers: Vec<String> },
29
30    /// Advanced format - provide complete XDR auth entries as base64-encoded strings
31    Xdr { entries: Vec<String> },
32}
33
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
35#[serde(tag = "type", rename_all = "snake_case")]
36pub enum OperationSpec {
37    Payment {
38        destination: String,
39        /// Amount in stroops, encoded as a decimal string to preserve precision.
40        // String serde keeps large integers precise through storage
41        // serialization; `value_type = String` syncs the OpenAPI schema.
42        // See `serialize_i64`.
43        #[serde(serialize_with = "serialize_i64", deserialize_with = "deserialize_i64")]
44        #[schema(value_type = String, pattern = "^-?[0-9]+$")]
45        amount: i64,
46        asset: AssetSpec,
47    },
48    InvokeContract {
49        contract_address: String,
50        function_name: String,
51        args: Vec<serde_json::Value>,
52        #[serde(skip_serializing_if = "Option::is_none")]
53        auth: Option<AuthSpec>,
54    },
55    CreateContract {
56        source: ContractSource,
57        wasm_hash: String,
58        #[serde(skip_serializing_if = "Option::is_none")]
59        salt: Option<String>,
60        #[serde(skip_serializing_if = "Option::is_none")]
61        constructor_args: Option<Vec<serde_json::Value>>,
62        #[serde(skip_serializing_if = "Option::is_none")]
63        auth: Option<AuthSpec>,
64    },
65    UploadWasm {
66        wasm: WasmSource,
67        #[serde(skip_serializing_if = "Option::is_none")]
68        auth: Option<AuthSpec>,
69    },
70}
71
72// Helper functions for OperationSpec conversion
73
74/// Parses a destination address into an XDR MuxedAccount
75fn parse_destination_address(destination: &str) -> Result<XdrMuxedAccount, SignerError> {
76    if let Ok(m) = MuxedAccount::from_string(destination) {
77        // accept M... muxed accounts
78        Ok(XdrMuxedAccount::MuxedEd25519(MuxedAccountMed25519 {
79            id: m.id,
80            ed25519: Uint256(m.ed25519),
81        }))
82    } else {
83        // fall-back to plain G... public key
84        let pk = PublicKey::from_string(destination)
85            .map_err(|e| SignerError::ConversionError(format!("Invalid destination: {e}")))?;
86        Ok(XdrMuxedAccount::Ed25519(Uint256(pk.0)))
87    }
88}
89
90/// Creates a Soroban authorization entry for source account
91fn create_source_account_auth_entry(
92    function: SorobanAuthorizedFunction,
93) -> SorobanAuthorizationEntry {
94    SorobanAuthorizationEntry {
95        credentials: SorobanCredentials::SourceAccount,
96        root_invocation: SorobanAuthorizedInvocation {
97            function,
98            sub_invocations: VecM::default(),
99        },
100    }
101}
102
103/// Decodes XDR authorization entries from base64 strings
104fn decode_xdr_auth_entries(
105    xdr_entries: Vec<String>,
106) -> Result<Vec<SorobanAuthorizationEntry>, SignerError> {
107    use soroban_rs::xdr::{Limits, ReadXdr};
108
109    xdr_entries
110        .iter()
111        .map(|xdr_str| {
112            SorobanAuthorizationEntry::from_xdr_base64(xdr_str, Limits::none())
113                .map_err(|e| SignerError::ConversionError(format!("Invalid auth XDR: {e}")))
114        })
115        .collect()
116}
117
118/// Generates default authorization entries for host functions that require them
119fn generate_default_auth_entries(
120    host_function: &HostFunction,
121) -> Result<Vec<SorobanAuthorizationEntry>, SignerError> {
122    match host_function {
123        HostFunction::CreateContract(ref create_args) => {
124            let auth_entry = create_source_account_auth_entry(
125                SorobanAuthorizedFunction::CreateContractHostFn(create_args.clone()),
126            );
127            Ok(vec![auth_entry])
128        }
129        HostFunction::CreateContractV2(ref create_args_v2) => {
130            let auth_entry = create_source_account_auth_entry(
131                SorobanAuthorizedFunction::CreateContractV2HostFn(create_args_v2.clone()),
132            );
133            Ok(vec![auth_entry])
134        }
135        HostFunction::InvokeContract(ref invoke_args) => {
136            let auth_entry = create_source_account_auth_entry(
137                SorobanAuthorizedFunction::ContractFn(invoke_args.clone()),
138            );
139            Ok(vec![auth_entry])
140        }
141        _ => Ok(vec![]),
142    }
143}
144
145/// Converts authorization spec and host function into authorization vector
146fn build_auth_vector(
147    auth: Option<AuthSpec>,
148    host_function: &HostFunction,
149) -> Result<VecM<SorobanAuthorizationEntry, { u32::MAX }>, SignerError> {
150    let auth_entries = match auth {
151        Some(AuthSpec::None) => vec![],
152        Some(AuthSpec::SourceAccount) => generate_default_auth_entries(host_function)?,
153        Some(AuthSpec::Addresses { signers: _ }) => {
154            // TODO: Implement address-based auth in the future
155            return Err(SignerError::ConversionError(
156                "Address-based auth not yet implemented".into(),
157            ));
158        }
159        Some(AuthSpec::Xdr { entries }) => decode_xdr_auth_entries(entries)?,
160        None => generate_default_auth_entries(host_function)?,
161    };
162
163    auth_entries
164        .try_into()
165        .map_err(|e| SignerError::ConversionError(format!("Failed to convert auth entries: {e:?}")))
166}
167
168/// Converts Payment operation spec to Operation
169fn convert_payment_operation(
170    destination: String,
171    amount: i64,
172    asset: AssetSpec,
173) -> Result<Operation, SignerError> {
174    let dest = parse_destination_address(&destination)?;
175
176    Ok(Operation {
177        source_account: None,
178        body: OperationBody::Payment(PaymentOp {
179            destination: dest,
180            asset: asset.try_into()?,
181            amount,
182        }),
183    })
184}
185
186/// Converts InvokeContract operation to XDR Operation
187fn convert_invoke_contract_operation(
188    contract_address: String,
189    function_name: String,
190    args: Vec<serde_json::Value>,
191    auth: Option<AuthSpec>,
192) -> Result<Operation, SignerError> {
193    use crate::models::transaction::stellar::host_function::HostFunctionSpec;
194
195    // Create HostFunctionSpec for backward compatibility
196    let spec = HostFunctionSpec::InvokeContract {
197        contract_address,
198        function_name,
199        args,
200    };
201
202    // Convert to HostFunction
203    let host_function = HostFunction::try_from(spec)?;
204
205    // Build authorization vector
206    let auth_vec = build_auth_vector(auth, &host_function)?;
207
208    Ok(Operation {
209        source_account: None,
210        body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
211            auth: auth_vec,
212            host_function,
213        }),
214    })
215}
216
217/// Converts CreateContract operation to XDR Operation
218fn convert_create_contract_operation(
219    source: ContractSource,
220    wasm_hash: String,
221    salt: Option<String>,
222    constructor_args: Option<Vec<serde_json::Value>>,
223    auth: Option<AuthSpec>,
224) -> Result<Operation, SignerError> {
225    use crate::models::transaction::stellar::host_function::HostFunctionSpec;
226
227    // Create HostFunctionSpec for backward compatibility
228    let spec = HostFunctionSpec::CreateContract {
229        source,
230        wasm_hash,
231        salt,
232        constructor_args,
233    };
234
235    // Convert to HostFunction
236    let host_function = HostFunction::try_from(spec)?;
237
238    // Build authorization vector
239    let auth_vec = build_auth_vector(auth, &host_function)?;
240
241    Ok(Operation {
242        source_account: None,
243        body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
244            auth: auth_vec,
245            host_function,
246        }),
247    })
248}
249
250/// Converts UploadWasm operation to XDR Operation
251fn convert_upload_wasm_operation(
252    wasm: WasmSource,
253    auth: Option<AuthSpec>,
254) -> Result<Operation, SignerError> {
255    use crate::models::transaction::stellar::host_function::HostFunctionSpec;
256
257    // Create HostFunctionSpec for backward compatibility
258    let spec = HostFunctionSpec::UploadWasm { wasm };
259
260    // Convert to HostFunction
261    let host_function = HostFunction::try_from(spec)?;
262
263    // Build authorization vector
264    let auth_vec = build_auth_vector(auth, &host_function)?;
265
266    Ok(Operation {
267        source_account: None,
268        body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
269            auth: auth_vec,
270            host_function,
271        }),
272    })
273}
274
275impl TryFrom<OperationSpec> for Operation {
276    type Error = SignerError;
277
278    fn try_from(op: OperationSpec) -> Result<Self, Self::Error> {
279        match op {
280            OperationSpec::Payment {
281                destination,
282                amount,
283                asset,
284            } => convert_payment_operation(destination, amount, asset),
285
286            OperationSpec::InvokeContract {
287                contract_address,
288                function_name,
289                args,
290                auth,
291            } => convert_invoke_contract_operation(contract_address, function_name, args, auth),
292
293            OperationSpec::CreateContract {
294                source,
295                wasm_hash,
296                salt,
297                constructor_args,
298                auth,
299            } => convert_create_contract_operation(source, wasm_hash, salt, constructor_args, auth),
300
301            OperationSpec::UploadWasm { wasm, auth } => convert_upload_wasm_operation(wasm, auth),
302        }
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::models::transaction::stellar::host_function::ContractSource;
310    use soroban_rs::xdr::{
311        AccountId, ContractExecutable, ContractId, ContractIdPreimage,
312        ContractIdPreimageFromAddress, CreateContractArgs, CreateContractArgsV2, Hash,
313        PublicKey as XdrPublicKey, ScAddress,
314    };
315
316    const TEST_PK: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
317    const TEST_CONTRACT: &str = "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA";
318    const TEST_MUXED: &str =
319        "MAAAAAAAAAAAAAB7BQ2L7E5NBWMXDUCMZSIPOBKRDSBYVLMXGSSKF6YNPIB7Y77ITLVL6";
320
321    // Payment.amount is an i64 stored in Redis and re-encoded by the
322    // partial_update Lua script, so it must be serialized as a cjson-safe
323    // string (large stroop amounts would otherwise be floatified).
324    #[test]
325    fn test_payment_amount_serializes_as_string() {
326        let op = OperationSpec::Payment {
327            destination: TEST_PK.to_string(),
328            amount: 643918676885760,
329            asset: AssetSpec::Native,
330        };
331        let json = serde_json::to_string(&op).unwrap();
332        assert!(
333            json.contains(r#""amount":"643918676885760""#),
334            "amount should serialize as a string, got: {json}"
335        );
336    }
337
338    #[test]
339    fn test_payment_amount_deserializes_from_corrupted_float() {
340        let op = OperationSpec::Payment {
341            destination: TEST_PK.to_string(),
342            amount: 643918676885760,
343            asset: AssetSpec::Native,
344        };
345        let mut value = serde_json::to_value(&op).unwrap();
346        value["amount"] = serde_json::json!(643918676885760.0);
347        let json = serde_json::to_string(&value).unwrap();
348
349        let parsed: OperationSpec = serde_json::from_str(&json).unwrap();
350        match parsed {
351            OperationSpec::Payment { amount, .. } => assert_eq!(amount, 643918676885760),
352            other => panic!("expected Payment, got {other:?}"),
353        }
354    }
355
356    mod parse_destination_address_tests {
357        use super::*;
358
359        #[test]
360        fn test_regular_public_key() {
361            let result = parse_destination_address(TEST_PK).unwrap();
362            assert!(matches!(result, XdrMuxedAccount::Ed25519(_)));
363        }
364
365        #[test]
366        fn test_muxed_account() {
367            let result = parse_destination_address(TEST_MUXED).unwrap();
368            assert!(matches!(result, XdrMuxedAccount::MuxedEd25519(_)));
369        }
370
371        #[test]
372        fn test_invalid_address() {
373            let result = parse_destination_address("INVALID");
374            assert!(result.is_err());
375        }
376    }
377
378    mod create_source_account_auth_entry_tests {
379        use super::*;
380        use soroban_rs::xdr::Uint256;
381
382        #[test]
383        fn test_creates_correct_structure() {
384            let function = SorobanAuthorizedFunction::CreateContractHostFn(CreateContractArgs {
385                contract_id_preimage: ContractIdPreimage::Address(ContractIdPreimageFromAddress {
386                    address: ScAddress::Account(AccountId(XdrPublicKey::PublicKeyTypeEd25519(
387                        Uint256([0u8; 32]),
388                    ))),
389                    salt: Uint256([0u8; 32]),
390                }),
391                executable: ContractExecutable::Wasm(Hash([0u8; 32])),
392            });
393
394            let entry = create_source_account_auth_entry(function.clone());
395            assert!(matches!(
396                entry.credentials,
397                SorobanCredentials::SourceAccount
398            ));
399            // Can't directly compare functions due to Clone requirement, but structure is validated
400        }
401    }
402
403    mod decode_xdr_auth_entries_tests {
404        use super::*;
405
406        #[test]
407        fn test_invalid_base64() {
408            let xdr_entries = vec!["!!!invalid!!!".to_string()];
409            let result = decode_xdr_auth_entries(xdr_entries);
410            assert!(result.is_err());
411        }
412
413        #[test]
414        fn test_malformed_xdr() {
415            let xdr_entries = vec!["dGVzdA==".to_string()]; // Valid base64 but not valid XDR
416            let result = decode_xdr_auth_entries(xdr_entries);
417            assert!(result.is_err());
418        }
419
420        #[test]
421        fn test_empty_list() {
422            let xdr_entries = vec![];
423            let result = decode_xdr_auth_entries(xdr_entries);
424            assert!(result.is_ok());
425            assert_eq!(result.unwrap().len(), 0);
426        }
427    }
428
429    mod generate_default_auth_entries_tests {
430        use super::*;
431        use soroban_rs::xdr::Uint256;
432
433        #[test]
434        fn test_create_contract() {
435            let host_function = HostFunction::CreateContract(CreateContractArgs {
436                contract_id_preimage: ContractIdPreimage::Address(ContractIdPreimageFromAddress {
437                    address: ScAddress::Account(AccountId(XdrPublicKey::PublicKeyTypeEd25519(
438                        Uint256([0u8; 32]),
439                    ))),
440                    salt: Uint256([0u8; 32]),
441                }),
442                executable: ContractExecutable::Wasm(Hash([0u8; 32])),
443            });
444
445            let result = generate_default_auth_entries(&host_function);
446            assert!(result.is_ok());
447            assert_eq!(result.unwrap().len(), 1);
448        }
449
450        #[test]
451        fn test_create_contract_v2() {
452            let host_function = HostFunction::CreateContractV2(CreateContractArgsV2 {
453                contract_id_preimage: ContractIdPreimage::Address(ContractIdPreimageFromAddress {
454                    address: ScAddress::Account(AccountId(XdrPublicKey::PublicKeyTypeEd25519(
455                        Uint256([0u8; 32]),
456                    ))),
457                    salt: Uint256([0u8; 32]),
458                }),
459                executable: ContractExecutable::Wasm(Hash([0u8; 32])),
460                constructor_args: VecM::default(),
461            });
462
463            let result = generate_default_auth_entries(&host_function);
464            assert!(result.is_ok());
465            assert_eq!(result.unwrap().len(), 1);
466        }
467
468        #[test]
469        fn test_invoke_contract() {
470            let host_function = HostFunction::InvokeContract(soroban_rs::xdr::InvokeContractArgs {
471                contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))),
472                function_name: soroban_rs::xdr::ScSymbol::try_from(b"test".to_vec()).unwrap(),
473                args: VecM::default(),
474            });
475
476            let result = generate_default_auth_entries(&host_function);
477            assert!(result.is_ok());
478            assert_eq!(result.unwrap().len(), 1);
479        }
480
481        #[test]
482        fn test_other_operations() {
483            let host_function = HostFunction::UploadContractWasm(vec![0u8; 10].try_into().unwrap());
484
485            let result = generate_default_auth_entries(&host_function);
486            assert!(result.is_ok());
487            assert_eq!(result.unwrap().len(), 0);
488        }
489    }
490
491    mod build_auth_vector_tests {
492        use super::*;
493        use soroban_rs::xdr::Uint256;
494
495        #[test]
496        fn test_simple_auth() {
497            let host_function = HostFunction::CreateContract(CreateContractArgs {
498                contract_id_preimage: ContractIdPreimage::Address(ContractIdPreimageFromAddress {
499                    address: ScAddress::Account(AccountId(XdrPublicKey::PublicKeyTypeEd25519(
500                        Uint256([0u8; 32]),
501                    ))),
502                    salt: Uint256([0u8; 32]),
503                }),
504                executable: ContractExecutable::Wasm(Hash([0u8; 32])),
505            });
506
507            let auth = Some(AuthSpec::SourceAccount);
508            let result = build_auth_vector(auth, &host_function);
509
510            assert!(result.is_ok());
511            assert_eq!(result.unwrap().len(), 1);
512        }
513
514        #[test]
515        fn test_xdr_auth_invalid() {
516            let host_function = HostFunction::CreateContract(CreateContractArgs {
517                contract_id_preimage: ContractIdPreimage::Address(ContractIdPreimageFromAddress {
518                    address: ScAddress::Account(AccountId(XdrPublicKey::PublicKeyTypeEd25519(
519                        Uint256([0u8; 32]),
520                    ))),
521                    salt: Uint256([0u8; 32]),
522                }),
523                executable: ContractExecutable::Wasm(Hash([0u8; 32])),
524            });
525
526            let auth = Some(AuthSpec::Xdr {
527                entries: vec!["invalid".to_string()],
528            });
529            let result = build_auth_vector(auth, &host_function);
530
531            assert!(result.is_err());
532        }
533
534        #[test]
535        fn test_none_default_create_contract() {
536            let host_function = HostFunction::CreateContract(CreateContractArgs {
537                contract_id_preimage: ContractIdPreimage::Address(ContractIdPreimageFromAddress {
538                    address: ScAddress::Account(AccountId(XdrPublicKey::PublicKeyTypeEd25519(
539                        Uint256([0u8; 32]),
540                    ))),
541                    salt: Uint256([0u8; 32]),
542                }),
543                executable: ContractExecutable::Wasm(Hash([0u8; 32])),
544            });
545
546            let result = build_auth_vector(None, &host_function);
547
548            assert!(result.is_ok());
549            assert_eq!(result.unwrap().len(), 1);
550        }
551
552        #[test]
553        fn test_none_default_invoke_contract() {
554            let host_function = HostFunction::InvokeContract(soroban_rs::xdr::InvokeContractArgs {
555                contract_address: ScAddress::Contract(ContractId(Hash([0u8; 32]))),
556                function_name: soroban_rs::xdr::ScSymbol::try_from(b"test".to_vec()).unwrap(),
557                args: VecM::default(),
558            });
559
560            let result = build_auth_vector(None, &host_function);
561
562            assert!(result.is_ok());
563            assert_eq!(result.unwrap().len(), 1);
564        }
565    }
566
567    mod convert_payment_operation_tests {
568        use super::*;
569
570        #[test]
571        fn test_with_native_asset() {
572            let result = convert_payment_operation(TEST_PK.to_string(), 1000, AssetSpec::Native);
573
574            assert!(result.is_ok());
575            if let Operation {
576                body: OperationBody::Payment(op),
577                ..
578            } = result.unwrap()
579            {
580                assert_eq!(op.amount, 1000);
581                assert!(matches!(op.asset, soroban_rs::xdr::Asset::Native));
582            } else {
583                panic!("Expected Payment operation");
584            }
585        }
586
587        #[test]
588        fn test_with_credit_asset() {
589            let result = convert_payment_operation(
590                TEST_PK.to_string(),
591                500,
592                AssetSpec::Credit4 {
593                    code: "USDC".to_string(),
594                    issuer: TEST_PK.to_string(),
595                },
596            );
597
598            assert!(result.is_ok());
599        }
600
601        #[test]
602        fn test_invalid_destination() {
603            let result = convert_payment_operation("INVALID".to_string(), 1000, AssetSpec::Native);
604
605            assert!(result.is_err());
606        }
607
608        #[test]
609        fn test_invalid_asset() {
610            let result = convert_payment_operation(
611                TEST_PK.to_string(),
612                1000,
613                AssetSpec::Credit4 {
614                    code: "TOOLONG".to_string(),
615                    issuer: TEST_PK.to_string(),
616                },
617            );
618
619            assert!(result.is_err());
620        }
621    }
622
623    mod convert_invoke_contract_operation_tests {
624        use super::*;
625
626        #[test]
627        fn test_basic_contract_invocation() {
628            let result = convert_invoke_contract_operation(
629                TEST_CONTRACT.to_string(),
630                "test".to_string(),
631                vec![],
632                None,
633            );
634            assert!(result.is_ok());
635        }
636
637        #[test]
638        fn test_with_auth() {
639            let auth = Some(AuthSpec::SourceAccount);
640            let result = convert_invoke_contract_operation(
641                TEST_CONTRACT.to_string(),
642                "transfer".to_string(),
643                vec![],
644                auth,
645            );
646
647            assert!(result.is_ok());
648            if let Operation {
649                body: OperationBody::InvokeHostFunction(op),
650                ..
651            } = result.unwrap()
652            {
653                assert_eq!(op.auth.len(), 1);
654            } else {
655                panic!("Expected InvokeHostFunction operation");
656            }
657        }
658    }
659
660    mod convert_create_contract_operation_tests {
661        use super::*;
662
663        #[test]
664        fn test_create_contract() {
665            let source = ContractSource::Address {
666                address: TEST_PK.to_string(),
667            };
668            let wasm_hash =
669                "0000000000000000000000000000000000000000000000000000000000000001".to_string();
670
671            let result = convert_create_contract_operation(source, wasm_hash, None, None, None);
672            assert!(result.is_ok());
673        }
674    }
675
676    // Integration tests
677    #[test]
678    fn test_payment_operation() {
679        let spec = OperationSpec::Payment {
680            destination: TEST_PK.to_string(),
681            amount: 1000,
682            asset: AssetSpec::Native,
683        };
684
685        let result = Operation::try_from(spec);
686        assert!(result.is_ok());
687        assert!(matches!(result.unwrap().body, OperationBody::Payment(_)));
688    }
689
690    #[test]
691    fn test_invoke_contract_operation() {
692        let spec = OperationSpec::InvokeContract {
693            contract_address: TEST_CONTRACT.to_string(),
694            function_name: "test".to_string(),
695            args: vec![],
696            auth: None,
697        };
698
699        let result = Operation::try_from(spec);
700        assert!(result.is_ok());
701        assert!(matches!(
702            result.unwrap().body,
703            OperationBody::InvokeHostFunction(_)
704        ));
705    }
706
707    #[test]
708    fn test_operation_spec_serde() {
709        let spec = OperationSpec::Payment {
710            destination: TEST_PK.to_string(),
711            amount: 1000,
712            asset: AssetSpec::Native,
713        };
714        let json = serde_json::to_string(&spec).unwrap();
715        assert!(json.contains("payment"));
716        assert!(json.contains("native"));
717
718        let deserialized: OperationSpec = serde_json::from_str(&json).unwrap();
719        assert_eq!(spec, deserialized);
720    }
721
722    #[test]
723    fn test_auth_spec_serde() {
724        let spec = AuthSpec::SourceAccount;
725        let json = serde_json::to_string(&spec).unwrap();
726        assert!(json.contains("source_account"));
727
728        let deserialized: AuthSpec = serde_json::from_str(&json).unwrap();
729        assert_eq!(spec, deserialized);
730    }
731
732    #[test]
733    fn test_auth_spec_json_format() {
734        // Test None
735        let none = AuthSpec::None;
736        let none_json = serde_json::to_value(&none).unwrap();
737        assert_eq!(none_json["type"], "none");
738
739        // Test SourceAccount
740        let source = AuthSpec::SourceAccount;
741        let source_json = serde_json::to_value(&source).unwrap();
742        assert_eq!(source_json["type"], "source_account");
743
744        // Test Addresses
745        let addresses = AuthSpec::Addresses {
746            signers: vec![TEST_PK.to_string()],
747        };
748        let addresses_json = serde_json::to_value(&addresses).unwrap();
749        assert_eq!(addresses_json["type"], "addresses");
750        assert!(addresses_json["signers"].is_array());
751
752        // Test Xdr
753        let xdr = AuthSpec::Xdr {
754            entries: vec!["base64data".to_string()],
755        };
756        let xdr_json = serde_json::to_value(&xdr).unwrap();
757        assert_eq!(xdr_json["type"], "xdr");
758        assert!(xdr_json["entries"].is_array());
759    }
760
761    #[test]
762    fn test_operation_spec_json_format() {
763        // Test Payment
764        let payment = OperationSpec::Payment {
765            destination: TEST_PK.to_string(),
766            amount: 1000,
767            asset: AssetSpec::Native,
768        };
769        let payment_json = serde_json::to_value(&payment).unwrap();
770        assert_eq!(payment_json["type"], "payment");
771        assert_eq!(payment_json["asset"]["type"], "native");
772
773        // Test InvokeContract
774        let invoke = OperationSpec::InvokeContract {
775            contract_address: TEST_CONTRACT.to_string(),
776            function_name: "test".to_string(),
777            args: vec![],
778            auth: None,
779        };
780        let invoke_json = serde_json::to_value(&invoke).unwrap();
781        assert_eq!(invoke_json["type"], "invoke_contract");
782        assert_eq!(invoke_json["contract_address"], TEST_CONTRACT);
783        assert_eq!(invoke_json["function_name"], "test");
784        assert!(invoke_json["args"].is_array());
785    }
786
787    #[test]
788    fn test_invoke_contract_with_source_account_auth_integration() {
789        // Create a realistic InvokeContract operation with source account auth
790        let spec = OperationSpec::InvokeContract {
791            contract_address: TEST_CONTRACT.to_string(),
792            function_name: "transfer".to_string(),
793            args: vec![], // In real scenario, this would contain transfer args
794            auth: Some(AuthSpec::SourceAccount),
795        };
796
797        // Convert to XDR Operation
798        let result = Operation::try_from(spec);
799        assert!(result.is_ok());
800
801        let operation = result.unwrap();
802        match operation.body {
803            OperationBody::InvokeHostFunction(ref invoke_op) => {
804                // Verify auth entries were created
805                assert_eq!(invoke_op.auth.len(), 1);
806
807                // Verify it's a source account credential
808                let auth_entry = &invoke_op.auth[0];
809                assert!(matches!(
810                    auth_entry.credentials,
811                    SorobanCredentials::SourceAccount
812                ));
813
814                // Verify the authorized function matches our contract invocation
815                match &auth_entry.root_invocation.function {
816                    SorobanAuthorizedFunction::ContractFn(invoke_args) => {
817                        // The contract address and function name should match
818                        assert!(matches!(
819                            invoke_args.contract_address,
820                            ScAddress::Contract(_)
821                        ));
822                        assert_eq!(invoke_args.function_name.0.as_slice(), b"transfer");
823                    }
824                    _ => panic!("Expected ContractFn authorization"),
825                }
826            }
827            _ => panic!("Expected InvokeHostFunction operation"),
828        }
829    }
830
831    #[test]
832    fn test_invoke_contract_with_none_auth_gets_default() {
833        // Create InvokeContract operation with NO auth specified
834        let spec = OperationSpec::InvokeContract {
835            contract_address: TEST_CONTRACT.to_string(),
836            function_name: "mint".to_string(),
837            args: vec![],
838            auth: None, // No auth specified - should get default source account
839        };
840
841        // Convert to XDR Operation
842        let result = Operation::try_from(spec);
843        assert!(result.is_ok());
844
845        let operation = result.unwrap();
846        match operation.body {
847            OperationBody::InvokeHostFunction(ref invoke_op) => {
848                // Verify default auth entry was created
849                assert_eq!(invoke_op.auth.len(), 1);
850
851                // Verify it's a source account credential
852                let auth_entry = &invoke_op.auth[0];
853                assert!(matches!(
854                    auth_entry.credentials,
855                    SorobanCredentials::SourceAccount
856                ));
857
858                // Verify the authorized function matches our contract invocation
859                match &auth_entry.root_invocation.function {
860                    SorobanAuthorizedFunction::ContractFn(invoke_args) => {
861                        assert_eq!(invoke_args.function_name.0.as_slice(), b"mint");
862                    }
863                    _ => panic!("Expected ContractFn authorization"),
864                }
865            }
866            _ => panic!("Expected InvokeHostFunction operation"),
867        }
868    }
869}