Skip to main content

sdkt_xdr/
builder.rs

1//! Transaction envelope building logic for Soroban.
2//!
3//! Provides a streamlined interface for assembling standard Stellar/Soroban
4//! transactions without implementing signing (which is handled later).
5
6use crate::DecodeError;
7use base64::engine::general_purpose::STANDARD;
8use base64::Engine;
9use sha2::{Digest, Sha256};
10use stellar_strkey::Strkey;
11use stellar_xdr::{
12    AccountId, BytesM, ContractExecutable, ContractId, ContractIdPreimage,
13    ContractIdPreimageFromAddress, CreateContractArgs, CreateContractArgsV2, ExtendFootprintTtlOp,
14    ExtensionPoint, Hash, HashIdPreimage, HashIdPreimageContractId, HostFunction,
15    InvokeContractArgs, InvokeHostFunctionOp, LedgerFootprint, LedgerKey, Memo, MuxedAccount,
16    Operation, OperationBody, Preconditions, PublicKey, ReadXdr, RestoreFootprintOp, ScAddress,
17    ScSymbol, ScVal, SequenceNumber, SorobanAuthorizationEntry, SorobanResources,
18    SorobanTransactionData, SorobanTransactionDataExt, Transaction, TransactionEnvelope,
19    TransactionExt, TransactionV1Envelope, Uint256, VecM, WriteXdr,
20};
21
22/// Parameters for building a basic contract invocation transaction.
23#[derive(Debug, Clone)]
24pub struct InvokeTransactionParams {
25    /// Source account public key (G...)
26    pub source_account: String,
27    /// Next sequence number for the source account
28    pub sequence: i64,
29    /// Transaction fee in stroops
30    pub fee: u32,
31    /// Contract ID to invoke (C...)
32    pub contract_id: String,
33    /// Function name
34    pub function: String,
35    /// Optional arguments (as pre-encoded ScVal base64 strings)
36    pub args: Vec<String>,
37    /// Optional transaction memo. `None` builds `Memo::None` (unchanged default).
38    pub memo: Option<Memo>,
39}
40
41/// Build a `Memo::Text`, enforcing the protocol's 28-byte limit.
42///
43/// Mirrors the validation the core transaction builder performs, so an
44/// over-long memo is rejected here with an actionable message instead of being
45/// silently truncated.
46pub fn memo_text(text: &str) -> Result<Memo, DecodeError> {
47    let bytes = text.as_bytes();
48    let vec_m = bytes.try_into().map_err(|_| {
49        DecodeError::Extraction(format!(
50            "memo text must be at most 28 bytes (got {})",
51            bytes.len()
52        ))
53    })?;
54    Ok(Memo::Text(vec_m))
55}
56
57/// Build a `Memo::Id`.
58pub fn memo_id(id: u64) -> Memo {
59    Memo::Id(id)
60}
61
62/// Decode a G... StrKey into an `AccountId`.
63pub fn decode_account_id(pubkey: &str) -> Result<AccountId, DecodeError> {
64    let key = Strkey::from_string(pubkey)
65        .map_err(|e| DecodeError::Extraction(format!("Invalid public key: {}", e)))?;
66
67    match key {
68        Strkey::PublicKeyEd25519(pk) => {
69            Ok(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(pk.0))))
70        }
71        _ => Err(DecodeError::Extraction(
72            "Expected ED25519 Public Key".into(),
73        )),
74    }
75}
76
77/// Decode a C... StrKey into a 32-byte `Hash`.
78pub fn decode_contract_id(contract_id: &str) -> Result<Hash, DecodeError> {
79    let key = Strkey::from_string(contract_id)
80        .map_err(|e| DecodeError::Extraction(format!("Invalid contract ID: {}", e)))?;
81
82    match key {
83        Strkey::Contract(c) => Ok(Hash(c.0)),
84        _ => Err(DecodeError::Extraction("Expected Contract ID".into())),
85    }
86}
87
88/// Builds a `TransactionEnvelope` (V1) for invoking a smart contract.
89pub fn build_invoke_transaction(params: &InvokeTransactionParams) -> Result<String, DecodeError> {
90    let source_account = decode_account_id(&params.source_account)?;
91    let contract_hash = decode_contract_id(&params.contract_id)?;
92
93    // Parse ScVal args from Base64
94    let mut scval_args = Vec::new();
95    for arg_b64 in &params.args {
96        let raw = STANDARD.decode(arg_b64)?;
97        let mut cursor = std::io::Cursor::new(&raw);
98        let mut l = stellar_xdr::Limited::new(&mut cursor, stellar_xdr::Limits::none());
99        let val = stellar_xdr::ScVal::read_xdr(&mut l)
100            .map_err(|e| DecodeError::XdrParse("ScVal arg".into(), e))?;
101        scval_args.push(val);
102    }
103    let args_vec = VecM::try_from(scval_args)
104        .map_err(|_| DecodeError::Extraction("Too many arguments".into()))?;
105
106    let function_name = ScSymbol(
107        params
108            .function
109            .as_bytes()
110            .try_into()
111            .map_err(|_| DecodeError::Extraction("Function name too long".into()))?,
112    );
113
114    let invoke_op = InvokeHostFunctionOp {
115        host_function: HostFunction::InvokeContract(InvokeContractArgs {
116            contract_address: ScAddress::Contract(ContractId(contract_hash)),
117            function_name,
118            args: args_vec,
119        }),
120        auth: VecM::default(),
121    };
122
123    let op = Operation {
124        source_account: None,
125        body: OperationBody::InvokeHostFunction(invoke_op),
126    };
127
128    let tx = Transaction {
129        source_account: MuxedAccount::Ed25519(match source_account.0 {
130            PublicKey::PublicKeyTypeEd25519(u) => u,
131        }),
132        fee: params.fee,
133        seq_num: SequenceNumber(params.sequence),
134        cond: Preconditions::None,
135        memo: params.memo.clone().unwrap_or(Memo::None),
136        operations: VecM::try_from(vec![op]).unwrap(),
137        ext: TransactionExt::V0,
138    };
139
140    let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
141        tx,
142        signatures: VecM::default(), // No signatures applied yet
143    });
144
145    let mut buf = Vec::new();
146    let mut l = stellar_xdr::Limited::new(&mut buf, stellar_xdr::Limits::none());
147    envelope.write_xdr(&mut l).map_err(DecodeError::XdrWrite)?;
148
149    Ok(STANDARD.encode(&buf))
150}
151
152/// Builds a final contract-invocation envelope with `SorobanTransactionData`
153/// (ext V1 — required by the network for host-function transactions) and
154/// authorization entries returned by simulation. Sign after this.
155pub fn build_invoke_transaction_with_data(
156    params: &InvokeTransactionParams,
157    soroban_data: SorobanTransactionData,
158    auth_entries: Vec<SorobanAuthorizationEntry>,
159) -> Result<String, DecodeError> {
160    let source_account = decode_account_id(&params.source_account)?;
161    let contract_hash = decode_contract_id(&params.contract_id)?;
162
163    let mut scval_args = Vec::new();
164    for arg_b64 in &params.args {
165        let raw = STANDARD.decode(arg_b64)?;
166        let mut cursor = std::io::Cursor::new(&raw);
167        let mut l = stellar_xdr::Limited::new(&mut cursor, stellar_xdr::Limits::none());
168        let val = stellar_xdr::ScVal::read_xdr(&mut l)
169            .map_err(|e| DecodeError::XdrParse("ScVal arg".into(), e))?;
170        scval_args.push(val);
171    }
172    let args_vec = VecM::try_from(scval_args)
173        .map_err(|_| DecodeError::Extraction("Too many arguments".into()))?;
174
175    let function_name = ScSymbol(
176        params
177            .function
178            .as_bytes()
179            .try_into()
180            .map_err(|_| DecodeError::Extraction("Function name too long".into()))?,
181    );
182
183    let auth_vec_m = VecM::try_from(auth_entries)
184        .map_err(|_| DecodeError::Extraction("Too many auth entries".into()))?;
185
186    let invoke_op = InvokeHostFunctionOp {
187        host_function: HostFunction::InvokeContract(InvokeContractArgs {
188            contract_address: ScAddress::Contract(ContractId(contract_hash)),
189            function_name,
190            args: args_vec,
191        }),
192        auth: auth_vec_m,
193    };
194
195    let op = Operation {
196        source_account: None,
197        body: OperationBody::InvokeHostFunction(invoke_op),
198    };
199
200    let tx = Transaction {
201        source_account: MuxedAccount::Ed25519(match source_account.0 {
202            PublicKey::PublicKeyTypeEd25519(u) => u,
203        }),
204        fee: params.fee,
205        seq_num: SequenceNumber(params.sequence),
206        cond: Preconditions::None,
207        memo: params.memo.clone().unwrap_or(Memo::None),
208        operations: VecM::try_from(vec![op]).unwrap(),
209        ext: TransactionExt::V1(soroban_data),
210    };
211
212    let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
213        tx,
214        signatures: VecM::default(),
215    });
216
217    let mut buf = Vec::new();
218    let mut l = stellar_xdr::Limited::new(&mut buf, stellar_xdr::Limits::none());
219    envelope.write_xdr(&mut l).map_err(DecodeError::XdrWrite)?;
220
221    Ok(STANDARD.encode(&buf))
222}
223
224/// Parameters for building a `UploadContractWasm` transaction.
225pub struct UploadWasmParams {
226    /// Source account public key (G...)
227    pub source_account: String,
228    /// Next sequence number for the source account
229    pub sequence: i64,
230    /// Transaction fee in stroops
231    pub fee: u32,
232    /// Raw WASM bytecode to upload
233    pub wasm_bytes: Vec<u8>,
234}
235
236/// Parameters for building a `CreateContract` transaction.
237#[derive(Debug, Clone)]
238pub struct CreateContractParams {
239    /// Source account public key (G...)
240    pub source_account: String,
241    /// Next sequence number for the source account
242    pub sequence: i64,
243    /// Transaction fee in stroops
244    pub fee: u32,
245    /// WASM hash (32 bytes) from a prior upload
246    pub wasm_hash: [u8; 32],
247    /// Source account address for contract ID derivation
248    pub deployer_address: String,
249    /// 20-byte salt for unique contract ID
250    pub salt: [u8; 20],
251}
252
253/// Parameters for building a `CreateContractV2` transaction with constructor arguments.
254#[derive(Debug, Clone)]
255pub struct CreateContractV2Params {
256    /// Source account public key (G...)
257    pub source_account: String,
258    /// Next sequence number for the source account
259    pub sequence: i64,
260    /// Transaction fee in stroops
261    pub fee: u32,
262    /// WASM hash (32 bytes) from a prior upload
263    pub wasm_hash: [u8; 32],
264    /// Source account address for contract ID derivation
265    pub deployer_address: String,
266    /// 20-byte salt for unique contract ID
267    pub salt: [u8; 20],
268    /// Constructor arguments (as pre-encoded ScVal base64 strings)
269    pub constructor_args: Vec<String>,
270}
271
272/// Builds an initial `TransactionEnvelope` for uploading a WASM binary.
273/// This is used for simulation; the final transaction uses `build_upload_wasm_tx_with_data`.
274pub fn build_upload_wasm_tx(params: &UploadWasmParams) -> Result<String, DecodeError> {
275    let source_account = decode_account_id(&params.source_account)?;
276
277    let wasm_bytes_m = BytesM::try_from(params.wasm_bytes.as_slice())
278        .map_err(|_| DecodeError::Extraction("WASM bytes exceed max length".into()))?;
279
280    let upload_op = InvokeHostFunctionOp {
281        host_function: HostFunction::UploadContractWasm(wasm_bytes_m),
282        auth: VecM::default(),
283    };
284
285    let op = Operation {
286        source_account: None,
287        body: OperationBody::InvokeHostFunction(upload_op),
288    };
289
290    let tx = Transaction {
291        source_account: MuxedAccount::Ed25519(match source_account.0 {
292            PublicKey::PublicKeyTypeEd25519(u) => u,
293        }),
294        fee: params.fee,
295        seq_num: SequenceNumber(params.sequence),
296        cond: Preconditions::None,
297        memo: Memo::None,
298        operations: VecM::try_from(vec![op]).unwrap(),
299        ext: TransactionExt::V0,
300    };
301
302    let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
303        tx,
304        signatures: VecM::default(),
305    });
306
307    let mut buf = Vec::new();
308    let mut l = stellar_xdr::Limited::new(&mut buf, stellar_xdr::Limits::none());
309    envelope.write_xdr(&mut l).map_err(DecodeError::XdrWrite)?;
310
311    Ok(STANDARD.encode(&buf))
312}
313
314/// Builds a final `TransactionEnvelope` for uploading a WASM binary with SorobanTransactionData.
315pub fn build_upload_wasm_tx_with_data(
316    params: &UploadWasmParams,
317    soroban_data: SorobanTransactionData,
318) -> Result<String, DecodeError> {
319    let source_account = decode_account_id(&params.source_account)?;
320
321    let wasm_bytes_m = BytesM::try_from(params.wasm_bytes.as_slice())
322        .map_err(|_| DecodeError::Extraction("WASM bytes exceed max length".into()))?;
323
324    let upload_op = InvokeHostFunctionOp {
325        host_function: HostFunction::UploadContractWasm(wasm_bytes_m),
326        auth: VecM::default(),
327    };
328
329    let op = Operation {
330        source_account: None,
331        body: OperationBody::InvokeHostFunction(upload_op),
332    };
333
334    let tx = Transaction {
335        source_account: MuxedAccount::Ed25519(match source_account.0 {
336            PublicKey::PublicKeyTypeEd25519(u) => u,
337        }),
338        fee: params.fee,
339        seq_num: SequenceNumber(params.sequence),
340        cond: Preconditions::None,
341        memo: Memo::None,
342        operations: VecM::try_from(vec![op]).unwrap(),
343        ext: TransactionExt::V1(soroban_data),
344    };
345
346    let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
347        tx,
348        signatures: VecM::default(),
349    });
350
351    let mut buf = Vec::new();
352    let mut l = stellar_xdr::Limited::new(&mut buf, stellar_xdr::Limits::none());
353    envelope.write_xdr(&mut l).map_err(DecodeError::XdrWrite)?;
354
355    Ok(STANDARD.encode(&buf))
356}
357
358/// Builds an initial `TransactionEnvelope` for creating a contract instance from an uploaded WASM.
359/// This is used for simulation; the final transaction uses `build_create_contract_tx_with_data`.
360pub fn build_create_contract_tx(params: &CreateContractParams) -> Result<String, DecodeError> {
361    let source_account = decode_account_id(&params.source_account)?;
362    let deployer = decode_account_id(&params.deployer_address)?;
363
364    // Pad 20-byte salt to 32 bytes for Uint256
365    let mut salt_bytes = [0u8; 32];
366    salt_bytes[..params.salt.len()].copy_from_slice(&params.salt);
367
368    let preimage = ContractIdPreimage::Address(ContractIdPreimageFromAddress {
369        address: ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(
370            match deployer.0 {
371                PublicKey::PublicKeyTypeEd25519(u) => u,
372            },
373        ))),
374        salt: Uint256(salt_bytes),
375    });
376
377    let create_op = InvokeHostFunctionOp {
378        host_function: HostFunction::CreateContract(CreateContractArgs {
379            contract_id_preimage: preimage,
380            executable: ContractExecutable::Wasm(Hash(params.wasm_hash)),
381        }),
382        auth: VecM::default(),
383    };
384
385    let op = Operation {
386        source_account: None,
387        body: OperationBody::InvokeHostFunction(create_op),
388    };
389
390    let tx = Transaction {
391        source_account: MuxedAccount::Ed25519(match source_account.0 {
392            PublicKey::PublicKeyTypeEd25519(u) => u,
393        }),
394        fee: params.fee,
395        seq_num: SequenceNumber(params.sequence),
396        cond: Preconditions::None,
397        memo: Memo::None,
398        operations: VecM::try_from(vec![op]).unwrap(),
399        ext: TransactionExt::V0,
400    };
401
402    let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
403        tx,
404        signatures: VecM::default(),
405    });
406
407    let mut buf = Vec::new();
408    let mut l = stellar_xdr::Limited::new(&mut buf, stellar_xdr::Limits::none());
409    envelope.write_xdr(&mut l).map_err(DecodeError::XdrWrite)?;
410
411    Ok(STANDARD.encode(&buf))
412}
413
414/// Builds a final `TransactionEnvelope` for creating a contract instance with SorobanTransactionData.
415pub fn build_create_contract_tx_with_data(
416    params: &CreateContractParams,
417    soroban_data: SorobanTransactionData,
418) -> Result<String, DecodeError> {
419    let source_account = decode_account_id(&params.source_account)?;
420    let deployer = decode_account_id(&params.deployer_address)?;
421
422    // Pad 20-byte salt to 32 bytes for Uint256
423    let mut salt_bytes = [0u8; 32];
424    salt_bytes[..params.salt.len()].copy_from_slice(&params.salt);
425
426    let preimage = ContractIdPreimage::Address(ContractIdPreimageFromAddress {
427        address: ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(
428            match deployer.0 {
429                PublicKey::PublicKeyTypeEd25519(u) => u,
430            },
431        ))),
432        salt: Uint256(salt_bytes),
433    });
434
435    let create_op = InvokeHostFunctionOp {
436        host_function: HostFunction::CreateContract(CreateContractArgs {
437            contract_id_preimage: preimage,
438            executable: ContractExecutable::Wasm(Hash(params.wasm_hash)),
439        }),
440        auth: VecM::default(),
441    };
442
443    let op = Operation {
444        source_account: None,
445        body: OperationBody::InvokeHostFunction(create_op),
446    };
447
448    let tx = Transaction {
449        source_account: MuxedAccount::Ed25519(match source_account.0 {
450            PublicKey::PublicKeyTypeEd25519(u) => u,
451        }),
452        fee: params.fee,
453        seq_num: SequenceNumber(params.sequence),
454        cond: Preconditions::None,
455        memo: Memo::None,
456        operations: VecM::try_from(vec![op]).unwrap(),
457        ext: TransactionExt::V1(soroban_data),
458    };
459
460    let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
461        tx,
462        signatures: VecM::default(),
463    });
464
465    let mut buf = Vec::new();
466    let mut l = stellar_xdr::Limited::new(&mut buf, stellar_xdr::Limits::none());
467    envelope.write_xdr(&mut l).map_err(DecodeError::XdrWrite)?;
468
469    Ok(STANDARD.encode(&buf))
470}
471
472/// Builds a final `TransactionEnvelope` for uploading a WASM binary with SorobanTransactionData and auth entries.
473pub fn build_upload_wasm_tx_with_data_and_auth(
474    params: &UploadWasmParams,
475    soroban_data: SorobanTransactionData,
476    auth_entries: Vec<SorobanAuthorizationEntry>,
477) -> Result<String, DecodeError> {
478    let source_account = decode_account_id(&params.source_account)?;
479
480    let wasm_bytes_m = BytesM::try_from(params.wasm_bytes.as_slice())
481        .map_err(|_| DecodeError::Extraction("WASM bytes exceed max length".into()))?;
482
483    let auth_vec_m = VecM::try_from(auth_entries)
484        .map_err(|_| DecodeError::Extraction("Too many auth entries".into()))?;
485
486    let upload_op = InvokeHostFunctionOp {
487        host_function: HostFunction::UploadContractWasm(wasm_bytes_m),
488        auth: auth_vec_m,
489    };
490
491    let op = Operation {
492        source_account: None,
493        body: OperationBody::InvokeHostFunction(upload_op),
494    };
495
496    let tx = Transaction {
497        source_account: MuxedAccount::Ed25519(match source_account.0 {
498            PublicKey::PublicKeyTypeEd25519(u) => u,
499        }),
500        fee: params.fee,
501        seq_num: SequenceNumber(params.sequence),
502        cond: Preconditions::None,
503        memo: Memo::None,
504        operations: VecM::try_from(vec![op]).unwrap(),
505        ext: TransactionExt::V1(soroban_data),
506    };
507
508    let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
509        tx,
510        signatures: VecM::default(),
511    });
512
513    let mut buf = Vec::new();
514    let mut l = stellar_xdr::Limited::new(&mut buf, stellar_xdr::Limits::none());
515    envelope.write_xdr(&mut l).map_err(DecodeError::XdrWrite)?;
516
517    Ok(STANDARD.encode(&buf))
518}
519
520/// Builds a final `TransactionEnvelope` for creating a contract instance with SorobanTransactionData and auth entries.
521pub fn build_create_contract_tx_with_data_and_auth(
522    params: &CreateContractParams,
523    soroban_data: SorobanTransactionData,
524    auth_entries: Vec<SorobanAuthorizationEntry>,
525) -> Result<String, DecodeError> {
526    let source_account = decode_account_id(&params.source_account)?;
527    let deployer = decode_account_id(&params.deployer_address)?;
528
529    // Pad 20-byte salt to 32 bytes for Uint256
530    let mut salt_bytes = [0u8; 32];
531    salt_bytes[..params.salt.len()].copy_from_slice(&params.salt);
532
533    let preimage = ContractIdPreimage::Address(ContractIdPreimageFromAddress {
534        address: ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(
535            match deployer.0 {
536                PublicKey::PublicKeyTypeEd25519(u) => u,
537            },
538        ))),
539        salt: Uint256(salt_bytes),
540    });
541
542    let auth_vec_m = VecM::try_from(auth_entries)
543        .map_err(|_| DecodeError::Extraction("Too many auth entries".into()))?;
544
545    let create_op = InvokeHostFunctionOp {
546        host_function: HostFunction::CreateContract(CreateContractArgs {
547            contract_id_preimage: preimage,
548            executable: ContractExecutable::Wasm(Hash(params.wasm_hash)),
549        }),
550        auth: auth_vec_m,
551    };
552
553    let op = Operation {
554        source_account: None,
555        body: OperationBody::InvokeHostFunction(create_op),
556    };
557
558    let tx = Transaction {
559        source_account: MuxedAccount::Ed25519(match source_account.0 {
560            PublicKey::PublicKeyTypeEd25519(u) => u,
561        }),
562        fee: params.fee,
563        seq_num: SequenceNumber(params.sequence),
564        cond: Preconditions::None,
565        memo: Memo::None,
566        operations: VecM::try_from(vec![op]).unwrap(),
567        ext: TransactionExt::V1(soroban_data),
568    };
569
570    let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
571        tx,
572        signatures: VecM::default(),
573    });
574
575    let mut buf = Vec::new();
576    let mut l = stellar_xdr::Limited::new(&mut buf, stellar_xdr::Limits::none());
577    envelope.write_xdr(&mut l).map_err(DecodeError::XdrWrite)?;
578
579    Ok(STANDARD.encode(&buf))
580}
581
582/// Parse base64-encoded ScVal strings into a VecM<ScVal>.
583pub fn parse_scval_args(args: &[String]) -> Result<VecM<ScVal>, DecodeError> {
584    let mut scval_args = Vec::new();
585    for arg_b64 in args {
586        let raw = STANDARD.decode(arg_b64)?;
587        let mut cursor = std::io::Cursor::new(&raw);
588        let mut l = stellar_xdr::Limited::new(&mut cursor, stellar_xdr::Limits::none());
589        let val =
590            ScVal::read_xdr(&mut l).map_err(|e| DecodeError::XdrParse("ScVal arg".into(), e))?;
591        scval_args.push(val);
592    }
593    VecM::try_from(scval_args).map_err(|_| DecodeError::Extraction("Too many arguments".into()))
594}
595
596/// Builds an initial `TransactionEnvelope` for creating a contract instance with constructor arguments.
597/// This is used for simulation; the final transaction uses `build_create_contract_v2_tx_with_data_and_auth`.
598pub fn build_create_contract_v2_tx(params: &CreateContractV2Params) -> Result<String, DecodeError> {
599    let source_account = decode_account_id(&params.source_account)?;
600    let deployer = decode_account_id(&params.deployer_address)?;
601
602    // Pad 20-byte salt to 32 bytes for Uint256
603    let mut salt_bytes = [0u8; 32];
604    salt_bytes[..params.salt.len()].copy_from_slice(&params.salt);
605
606    let preimage = ContractIdPreimage::Address(ContractIdPreimageFromAddress {
607        address: ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(
608            match deployer.0 {
609                PublicKey::PublicKeyTypeEd25519(u) => u,
610            },
611        ))),
612        salt: Uint256(salt_bytes),
613    });
614
615    let constructor_args = parse_scval_args(&params.constructor_args)?;
616
617    let create_op = InvokeHostFunctionOp {
618        host_function: HostFunction::CreateContractV2(CreateContractArgsV2 {
619            contract_id_preimage: preimage,
620            executable: ContractExecutable::Wasm(Hash(params.wasm_hash)),
621            constructor_args,
622        }),
623        auth: VecM::default(),
624    };
625
626    let op = Operation {
627        source_account: None,
628        body: OperationBody::InvokeHostFunction(create_op),
629    };
630
631    let tx = Transaction {
632        source_account: MuxedAccount::Ed25519(match source_account.0 {
633            PublicKey::PublicKeyTypeEd25519(u) => u,
634        }),
635        fee: params.fee,
636        seq_num: SequenceNumber(params.sequence),
637        cond: Preconditions::None,
638        memo: Memo::None,
639        operations: VecM::try_from(vec![op]).unwrap(),
640        ext: TransactionExt::V0,
641    };
642
643    let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
644        tx,
645        signatures: VecM::default(),
646    });
647
648    let mut buf = Vec::new();
649    let mut l = stellar_xdr::Limited::new(&mut buf, stellar_xdr::Limits::none());
650    envelope.write_xdr(&mut l).map_err(DecodeError::XdrWrite)?;
651
652    Ok(STANDARD.encode(&buf))
653}
654
655/// Builds a `TransactionEnvelope` for creating a contract instance with constructor arguments and SorobanTransactionData.
656pub fn build_create_contract_v2_tx_with_data(
657    params: &CreateContractV2Params,
658    soroban_data: SorobanTransactionData,
659) -> Result<String, DecodeError> {
660    let source_account = decode_account_id(&params.source_account)?;
661    let deployer = decode_account_id(&params.deployer_address)?;
662
663    // Pad 20-byte salt to 32 bytes for Uint256
664    let mut salt_bytes = [0u8; 32];
665    salt_bytes[..params.salt.len()].copy_from_slice(&params.salt);
666
667    let preimage = ContractIdPreimage::Address(ContractIdPreimageFromAddress {
668        address: ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(
669            match deployer.0 {
670                PublicKey::PublicKeyTypeEd25519(u) => u,
671            },
672        ))),
673        salt: Uint256(salt_bytes),
674    });
675
676    let constructor_args = parse_scval_args(&params.constructor_args)?;
677
678    let create_op = InvokeHostFunctionOp {
679        host_function: HostFunction::CreateContractV2(CreateContractArgsV2 {
680            contract_id_preimage: preimage,
681            executable: ContractExecutable::Wasm(Hash(params.wasm_hash)),
682            constructor_args,
683        }),
684        auth: VecM::default(),
685    };
686
687    let op = Operation {
688        source_account: None,
689        body: OperationBody::InvokeHostFunction(create_op),
690    };
691
692    let tx = Transaction {
693        source_account: MuxedAccount::Ed25519(match source_account.0 {
694            PublicKey::PublicKeyTypeEd25519(u) => u,
695        }),
696        fee: params.fee,
697        seq_num: SequenceNumber(params.sequence),
698        cond: Preconditions::None,
699        memo: Memo::None,
700        operations: VecM::try_from(vec![op]).unwrap(),
701        ext: TransactionExt::V1(soroban_data),
702    };
703
704    let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
705        tx,
706        signatures: VecM::default(),
707    });
708
709    let mut buf = Vec::new();
710    let mut l = stellar_xdr::Limited::new(&mut buf, stellar_xdr::Limits::none());
711    envelope.write_xdr(&mut l).map_err(DecodeError::XdrWrite)?;
712
713    Ok(STANDARD.encode(&buf))
714}
715
716/// Builds a final `TransactionEnvelope` for creating a contract instance with constructor arguments, SorobanTransactionData, and auth entries.
717pub fn build_create_contract_v2_tx_with_data_and_auth(
718    params: &CreateContractV2Params,
719    soroban_data: SorobanTransactionData,
720    auth_entries: Vec<SorobanAuthorizationEntry>,
721) -> Result<String, DecodeError> {
722    let source_account = decode_account_id(&params.source_account)?;
723    let deployer = decode_account_id(&params.deployer_address)?;
724
725    // Pad 20-byte salt to 32 bytes for Uint256
726    let mut salt_bytes = [0u8; 32];
727    salt_bytes[..params.salt.len()].copy_from_slice(&params.salt);
728
729    let preimage = ContractIdPreimage::Address(ContractIdPreimageFromAddress {
730        address: ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(
731            match deployer.0 {
732                PublicKey::PublicKeyTypeEd25519(u) => u,
733            },
734        ))),
735        salt: Uint256(salt_bytes),
736    });
737
738    let auth_vec_m = VecM::try_from(auth_entries)
739        .map_err(|_| DecodeError::Extraction("Too many auth entries".into()))?;
740
741    let constructor_args = parse_scval_args(&params.constructor_args)?;
742
743    let create_op = InvokeHostFunctionOp {
744        host_function: HostFunction::CreateContractV2(CreateContractArgsV2 {
745            contract_id_preimage: preimage,
746            executable: ContractExecutable::Wasm(Hash(params.wasm_hash)),
747            constructor_args,
748        }),
749        auth: auth_vec_m,
750    };
751
752    let op = Operation {
753        source_account: None,
754        body: OperationBody::InvokeHostFunction(create_op),
755    };
756
757    let tx = Transaction {
758        source_account: MuxedAccount::Ed25519(match source_account.0 {
759            PublicKey::PublicKeyTypeEd25519(u) => u,
760        }),
761        fee: params.fee,
762        seq_num: SequenceNumber(params.sequence),
763        cond: Preconditions::None,
764        memo: Memo::None,
765        operations: VecM::try_from(vec![op]).unwrap(),
766        ext: TransactionExt::V1(soroban_data),
767    };
768
769    let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
770        tx,
771        signatures: VecM::default(),
772    });
773
774    let mut buf = Vec::new();
775    let mut l = stellar_xdr::Limited::new(&mut buf, stellar_xdr::Limits::none());
776    envelope.write_xdr(&mut l).map_err(DecodeError::XdrWrite)?;
777
778    Ok(STANDARD.encode(&buf))
779}
780
781/// Parse base64-encoded SorobanAuthorizationEntry from simulation response.
782pub fn parse_soroban_authorization_entry(
783    base64_data: &str,
784) -> Result<SorobanAuthorizationEntry, DecodeError> {
785    let raw = STANDARD.decode(base64_data).map_err(|e| {
786        DecodeError::Extraction(format!("Failed to decode SorobanAuthorizationEntry: {}", e))
787    })?;
788    let mut cursor = std::io::Cursor::new(&raw);
789    let mut l = stellar_xdr::Limited::new(&mut cursor, stellar_xdr::Limits::none());
790    SorobanAuthorizationEntry::read_xdr(&mut l)
791        .map_err(|e| DecodeError::XdrParse("SorobanAuthorizationEntry".into(), e))
792}
793
794/// Parse multiple base64-encoded SorobanAuthorizationEntry from simulation response.
795pub fn parse_soroban_authorization_entries(
796    auth_b64_list: &[String],
797) -> Result<Vec<SorobanAuthorizationEntry>, DecodeError> {
798    auth_b64_list
799        .iter()
800        .map(|s| parse_soroban_authorization_entry(s))
801        .collect::<Result<Vec<_>, _>>()
802}
803
804/// Parse base64-encoded SorobanTransactionData from simulation response.
805pub fn parse_soroban_transaction_data(
806    base64_data: &str,
807) -> Result<SorobanTransactionData, DecodeError> {
808    let raw = STANDARD.decode(base64_data).map_err(|e| {
809        DecodeError::Extraction(format!("Failed to decode SorobanTransactionData: {}", e))
810    })?;
811    let mut cursor = std::io::Cursor::new(&raw);
812    let mut l = stellar_xdr::Limited::new(&mut cursor, stellar_xdr::Limits::none());
813    SorobanTransactionData::read_xdr(&mut l)
814        .map_err(|e| DecodeError::XdrParse("SorobanTransactionData".into(), e))
815}
816
817/// Parameters for building an `ExtendFootprintTtl` transaction.
818pub struct ExtendFootprintParams {
819    /// Source account public key (G...)
820    pub source_account: String,
821    /// Next sequence number for the source account
822    pub sequence: i64,
823    /// Transaction fee in stroops
824    pub fee: u32,
825    /// Minimum TTL in ledgers (`extendTo`): the footprint entries will live at
826    /// least this many ledgers past the last closed ledger. Relative, not an
827    /// absolute ledger sequence — see `ExtendFootprintTTLOp` in
828    /// `Stellar-transaction.x`.
829    pub extend_to: u32,
830    /// Ledger keys (base64 XDR or hex-encoded XDR) whose TTL will be extended.
831    /// Placed in the read-only footprint of the simulation envelope.
832    pub footprint_keys: Vec<String>,
833}
834
835/// Decode a `LedgerKey` from base64 XDR or hex-encoded XDR bytes.
836pub fn decode_ledger_key(encoded: &str) -> Result<LedgerKey, DecodeError> {
837    let trimmed = encoded.trim();
838    if trimmed.is_empty() {
839        return Err(DecodeError::Extraction("empty LedgerKey".into()));
840    }
841    let raw = if let Ok(bytes) = STANDARD.decode(trimmed) {
842        bytes
843    } else if trimmed.len().is_multiple_of(2) && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
844        hex::decode(trimmed).map_err(DecodeError::Hex)?
845    } else {
846        return Err(DecodeError::Extraction(
847            "LedgerKey must be base64 XDR or even-length hex".into(),
848        ));
849    };
850    if raw.is_empty() {
851        return Err(DecodeError::Extraction("empty LedgerKey".into()));
852    }
853    let mut cursor = std::io::Cursor::new(&raw);
854    let mut l = stellar_xdr::Limited::new(&mut cursor, stellar_xdr::Limits::none());
855    LedgerKey::read_xdr(&mut l).map_err(|e| DecodeError::XdrParse("LedgerKey".into(), e))
856}
857
858/// Deduplicate encoded LedgerKeys while preserving order. Invalid keys error.
859pub fn merge_footprint_keys(keys: &[String]) -> Result<Vec<String>, DecodeError> {
860    let mut seen = std::collections::HashSet::new();
861    let mut out = Vec::new();
862    for k in keys {
863        let decoded = decode_ledger_key(k)?;
864        let mut buf = Vec::new();
865        let mut l = stellar_xdr::Limited::new(&mut buf, stellar_xdr::Limits::none());
866        decoded.write_xdr(&mut l).map_err(DecodeError::XdrWrite)?;
867        let canonical = STANDARD.encode(&buf);
868        if seen.insert(canonical.clone()) {
869            out.push(canonical);
870        }
871    }
872    if out.is_empty() {
873        return Err(DecodeError::Extraction(
874            "footprint must contain at least one LedgerKey".into(),
875        ));
876    }
877    Ok(out)
878}
879
880fn muxed_from_account(source_account: AccountId) -> MuxedAccount {
881    MuxedAccount::Ed25519(match source_account.0 {
882        PublicKey::PublicKeyTypeEd25519(u) => u,
883    })
884}
885
886fn encode_envelope(envelope: TransactionEnvelope) -> Result<String, DecodeError> {
887    let mut buf = Vec::new();
888    let mut l = stellar_xdr::Limited::new(&mut buf, stellar_xdr::Limits::none());
889    envelope.write_xdr(&mut l).map_err(DecodeError::XdrWrite)?;
890    Ok(STANDARD.encode(&buf))
891}
892
893fn empty_soroban_data_with_keys(keys: &[LedgerKey]) -> Result<SorobanTransactionData, DecodeError> {
894    let read_only = VecM::try_from(keys.to_vec())
895        .map_err(|_| DecodeError::Extraction("Too many footprint keys".into()))?;
896    Ok(SorobanTransactionData {
897        ext: SorobanTransactionDataExt::V0,
898        resources: SorobanResources {
899            footprint: LedgerFootprint {
900                read_only,
901                read_write: VecM::default(),
902            },
903            instructions: 0,
904            disk_read_bytes: 0,
905            write_bytes: 0,
906        },
907        resource_fee: 0,
908    })
909}
910
911fn build_extend_envelope(
912    params: &ExtendFootprintParams,
913    soroban_data: SorobanTransactionData,
914) -> Result<String, DecodeError> {
915    if params.extend_to == 0 {
916        return Err(DecodeError::Extraction(
917            "extend_to must be greater than 0".into(),
918        ));
919    }
920    let source_account = decode_account_id(&params.source_account)?;
921    let op = Operation {
922        source_account: None,
923        body: OperationBody::ExtendFootprintTtl(ExtendFootprintTtlOp {
924            ext: ExtensionPoint::V0,
925            extend_to: params.extend_to,
926        }),
927    };
928    let tx = Transaction {
929        source_account: muxed_from_account(source_account),
930        fee: params.fee,
931        seq_num: SequenceNumber(params.sequence),
932        cond: Preconditions::None,
933        memo: Memo::None,
934        operations: VecM::try_from(vec![op]).unwrap(),
935        ext: TransactionExt::V1(soroban_data),
936    };
937    encode_envelope(TransactionEnvelope::Tx(TransactionV1Envelope {
938        tx,
939        signatures: VecM::default(),
940    }))
941}
942
943/// Builds an initial `TransactionEnvelope` for `ExtendFootprintTtl` (simulation).
944///
945/// Uses `TransactionExt::V1` with a zero resource budget and the requested
946/// read-only footprint so `simulateTransaction` can fill in real costs.
947pub fn build_extend_footprint_tx(params: &ExtendFootprintParams) -> Result<String, DecodeError> {
948    let merged = merge_footprint_keys(&params.footprint_keys)?;
949    let decoded: Result<Vec<LedgerKey>, _> = merged.iter().map(|k| decode_ledger_key(k)).collect();
950    let data = empty_soroban_data_with_keys(&decoded?)?;
951    build_extend_envelope(params, data)
952}
953
954/// Builds the final `TransactionEnvelope` for `ExtendFootprintTtl` using the
955/// `SorobanTransactionData` returned by simulation (authoritative footprint + fees).
956pub fn build_extend_footprint_tx_with_data(
957    params: &ExtendFootprintParams,
958    soroban_data: SorobanTransactionData,
959) -> Result<String, DecodeError> {
960    build_extend_envelope(params, soroban_data)
961}
962
963/// Parameters for building a `RestoreFootprint` transaction.
964#[derive(Debug, Clone)]
965pub struct RestoreFootprintParams {
966    /// Source account public key (G...)
967    pub source_account: String,
968    /// Next sequence number for the source account
969    pub sequence: i64,
970    /// Transaction fee in stroops (must be >= min_resource_fee from preamble)
971    pub fee: u32,
972    /// `SorobanTransactionData` from the restore preamble (authoritative footprint)
973    pub soroban_data: SorobanTransactionData,
974}
975
976/// Builds a `TransactionEnvelope` for `RestoreFootprint`.
977///
978/// Uses the `SorobanTransactionData` from the restore preamble, which contains
979/// the authoritative footprint and resource bounds needed to restore archived entries.
980///
981/// Rejects an empty footprint (nothing to restore) and a fee below the
982/// preamble's `resource_fee`, which the network would refuse.
983pub fn build_restore_footprint_tx(params: &RestoreFootprintParams) -> Result<String, DecodeError> {
984    let footprint = &params.soroban_data.resources.footprint;
985    if footprint.read_only.is_empty() && footprint.read_write.is_empty() {
986        return Err(DecodeError::Extraction(
987            "restore footprint is empty: no archived entries to restore".into(),
988        ));
989    }
990    if i64::from(params.fee) < params.soroban_data.resource_fee {
991        return Err(DecodeError::Extraction(format!(
992            "restore fee {} is below the preamble resource fee {}",
993            params.fee, params.soroban_data.resource_fee
994        )));
995    }
996    let source_account = decode_account_id(&params.source_account)?;
997    let op = Operation {
998        source_account: None,
999        body: OperationBody::RestoreFootprint(RestoreFootprintOp {
1000            ext: ExtensionPoint::V0,
1001        }),
1002    };
1003    let tx = Transaction {
1004        source_account: muxed_from_account(source_account),
1005        fee: params.fee,
1006        seq_num: SequenceNumber(params.sequence),
1007        cond: Preconditions::None,
1008        memo: Memo::None,
1009        operations: VecM::try_from(vec![op]).unwrap(),
1010        ext: TransactionExt::V1(params.soroban_data.clone()),
1011    };
1012    encode_envelope(TransactionEnvelope::Tx(TransactionV1Envelope {
1013        tx,
1014        signatures: VecM::default(),
1015    }))
1016}
1017
1018/// Derives the contract ID from network ID, deployer address, and salt.
1019///
1020/// Uses Stellar's official formula:
1021/// 1. Build `HashIdPreimage::ContractId(HashIdPreimageContractId { network_id, contract_id_preimage })`
1022///    where `contract_id_preimage = ContractIdPreimage::Address(ContractIdPreimageFromAddress { address, salt })`.
1023/// 2. SHA-256 the XDR serialization of the full `HashIdPreimage`.
1024///
1025/// The `wasm_hash` parameter is not directly part of ID derivation but is retained
1026/// in the signature for interface consistency with the full `CreateContract` flow.
1027pub fn derive_contract_id(
1028    network_id: &[u8; 32],
1029    deployer_address: &str,
1030    salt: &[u8; 20],
1031    wasm_hash: &[u8; 32],
1032) -> Result<String, DecodeError> {
1033    let deployer = decode_account_id(deployer_address)?;
1034
1035    // Pad 20-byte salt to 32 bytes for Uint256 (left-aligned, big-endian)
1036    let mut salt_bytes = [0u8; 32];
1037    salt_bytes[..salt.len()].copy_from_slice(salt);
1038
1039    let contract_id_preimage = ContractIdPreimage::Address(ContractIdPreimageFromAddress {
1040        address: ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(
1041            match deployer.0 {
1042                PublicKey::PublicKeyTypeEd25519(u) => u,
1043            },
1044        ))),
1045        salt: Uint256(salt_bytes),
1046    });
1047
1048    // Official Stellar/Soroban: SHA-256(XDR(HashIdPreimage::ContractId))
1049    let full_preimage = HashIdPreimage::ContractId(HashIdPreimageContractId {
1050        network_id: Hash(*network_id),
1051        contract_id_preimage,
1052    });
1053
1054    let mut buf = Vec::new();
1055    let mut l = stellar_xdr::Limited::new(&mut buf, stellar_xdr::Limits::none());
1056    full_preimage
1057        .write_xdr(&mut l)
1058        .map_err(DecodeError::XdrWrite)?;
1059
1060    let hash = Sha256::digest(&buf);
1061    let _ = wasm_hash;
1062    Ok(hex::encode(hash))
1063}
1064
1065#[cfg(test)]
1066fn realistic_soroban_transaction_data() -> SorobanTransactionData {
1067    use stellar_xdr::{
1068        ContractDataDurability, LedgerFootprint, LedgerKey, LedgerKeyContractData, ScVal,
1069        SorobanResources, SorobanTransactionDataExt,
1070    };
1071
1072    let contract_key = LedgerKey::ContractData(LedgerKeyContractData {
1073        contract: ScAddress::Contract(ContractId(Hash([0; 32]))),
1074        key: ScVal::LedgerKeyContractInstance,
1075        durability: ContractDataDurability::Persistent,
1076    });
1077
1078    SorobanTransactionData {
1079        ext: SorobanTransactionDataExt::V0,
1080        resources: SorobanResources {
1081            footprint: LedgerFootprint {
1082                read_only: VecM::try_from(vec![contract_key]).unwrap(),
1083                read_write: VecM::default(),
1084            },
1085            instructions: 100_000_000,
1086            disk_read_bytes: 100_000,
1087            write_bytes: 100_000,
1088        },
1089        resource_fee: 50_000,
1090    }
1091}
1092
1093/// Create a test SorobanAuthorizationEntry with SourceAccount credentials.
1094#[cfg(test)]
1095fn test_auth_entry() -> SorobanAuthorizationEntry {
1096    use stellar_xdr::{SorobanAuthorizedFunction, SorobanAuthorizedInvocation, SorobanCredentials};
1097
1098    SorobanAuthorizationEntry {
1099        credentials: SorobanCredentials::SourceAccount,
1100        root_invocation: SorobanAuthorizedInvocation {
1101            function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs {
1102                contract_address: ScAddress::Contract(ContractId(Hash([0; 32]))),
1103                function_name: ScSymbol::try_from("hello").unwrap(),
1104                args: VecM::default(),
1105            }),
1106            sub_invocations: VecM::default(),
1107        },
1108    }
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113    use super::*;
1114    use stellar_xdr::{OperationBody, SorobanCredentials};
1115
1116    const TEST_SOURCE: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
1117    const TEST_CONTRACT: &str = "CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526";
1118
1119    #[test]
1120    fn test_decode_account_id_valid() {
1121        let acc = decode_account_id(TEST_SOURCE).unwrap();
1122        match acc.0 {
1123            PublicKey::PublicKeyTypeEd25519(_) => {}
1124        }
1125    }
1126
1127    #[test]
1128    fn test_decode_account_id_invalid() {
1129        assert!(
1130            decode_account_id("GBZXLHQZGOWBZY6W3U4Z7GZGGXYVQBZWYM3XEQZ7W5Z4QXYZ5Z3XYY").is_err()
1131        ); // too short
1132        assert!(
1133            decode_account_id("CAZXLHQZGOWBZY6W3U4Z7GZGGXYVQBZWYM3XEQZ7W5Z4QXYZ5Z3XYYYY").is_err()
1134        ); // Wrong type (Contract)
1135        assert!(decode_account_id("not-a-key").is_err());
1136    }
1137
1138    #[test]
1139    fn test_decode_contract_id_valid() {
1140        let hash = decode_contract_id(TEST_CONTRACT).unwrap();
1141        assert_eq!(hash.0.len(), 32);
1142    }
1143
1144    #[test]
1145    fn test_decode_contract_id_invalid() {
1146        assert!(decode_contract_id(TEST_SOURCE).is_err()); // Wrong type (Account)
1147    }
1148
1149    #[test]
1150    fn test_build_invoke_transaction() {
1151        let params = InvokeTransactionParams {
1152            source_account: TEST_SOURCE.to_string(),
1153            sequence: 12345,
1154            fee: 100,
1155            contract_id: TEST_CONTRACT.to_string(),
1156            function: "hello".to_string(),
1157            args: vec![],
1158            memo: None,
1159        };
1160
1161        let envelope = build_invoke_transaction(&params).unwrap();
1162
1163        // Ensure it encodes to base64
1164        let raw = STANDARD.decode(&envelope).unwrap();
1165
1166        // We can parse it back
1167        let mut cursor = std::io::Cursor::new(&raw);
1168        let mut l = stellar_xdr::Limited::new(&mut cursor, stellar_xdr::Limits::none());
1169        let env = TransactionEnvelope::read_xdr(&mut l).unwrap();
1170
1171        match env {
1172            TransactionEnvelope::Tx(v1) => {
1173                assert_eq!(v1.tx.fee, 100);
1174                assert_eq!(v1.tx.seq_num.0, 12345);
1175            }
1176            _ => panic!("Expected V1 envelope"),
1177        }
1178    }
1179
1180    #[test]
1181    fn test_build_invoke_transaction_with_args() {
1182        // ScVal::I32(42) base64 encoded
1183        let arg_b64 = "AAAABAAAACo=";
1184
1185        let params = InvokeTransactionParams {
1186            source_account: TEST_SOURCE.to_string(),
1187            sequence: 1,
1188            fee: 100,
1189            contract_id: TEST_CONTRACT.to_string(),
1190            function: "add".to_string(),
1191            args: vec![arg_b64.to_string()],
1192            memo: None,
1193        };
1194
1195        let envelope = build_invoke_transaction(&params).unwrap();
1196        assert!(!envelope.is_empty());
1197    }
1198
1199    fn envelope_memo(envelope_b64: &str) -> Memo {
1200        let raw = STANDARD.decode(envelope_b64).unwrap();
1201        let mut cursor = std::io::Cursor::new(&raw);
1202        let mut l = stellar_xdr::Limited::new(&mut cursor, stellar_xdr::Limits::none());
1203        match TransactionEnvelope::read_xdr(&mut l).unwrap() {
1204            TransactionEnvelope::Tx(v1) => v1.tx.memo,
1205            _ => panic!("Expected V1 envelope"),
1206        }
1207    }
1208
1209    #[test]
1210    fn test_build_invoke_transaction_with_memo_text() {
1211        let params = InvokeTransactionParams {
1212            source_account: TEST_SOURCE.to_string(),
1213            sequence: 7,
1214            fee: 100,
1215            contract_id: TEST_CONTRACT.to_string(),
1216            function: "transfer".to_string(),
1217            args: vec![],
1218            memo: Some(memo_text("deposit-123").unwrap()),
1219        };
1220
1221        let envelope = build_invoke_transaction(&params).unwrap();
1222        let memo = envelope_memo(&envelope);
1223
1224        assert!(matches!(memo, Memo::Text(_)));
1225        assert_eq!(memo, memo_text("deposit-123").unwrap());
1226    }
1227
1228    #[test]
1229    fn test_build_invoke_transaction_with_memo_id() {
1230        let params = InvokeTransactionParams {
1231            source_account: TEST_SOURCE.to_string(),
1232            sequence: 7,
1233            fee: 100,
1234            contract_id: TEST_CONTRACT.to_string(),
1235            function: "transfer".to_string(),
1236            args: vec![],
1237            memo: Some(memo_id(42)),
1238        };
1239
1240        let envelope = build_invoke_transaction(&params).unwrap();
1241        let memo = envelope_memo(&envelope);
1242
1243        assert!(matches!(memo, Memo::Id(_)));
1244        assert_eq!(memo, Memo::Id(42));
1245    }
1246
1247    #[test]
1248    fn test_build_invoke_transaction_without_memo_is_none() {
1249        let params = InvokeTransactionParams {
1250            source_account: TEST_SOURCE.to_string(),
1251            sequence: 7,
1252            fee: 100,
1253            contract_id: TEST_CONTRACT.to_string(),
1254            function: "transfer".to_string(),
1255            args: vec![],
1256            memo: None,
1257        };
1258
1259        let envelope = build_invoke_transaction(&params).unwrap();
1260        assert_eq!(envelope_memo(&envelope), Memo::None);
1261    }
1262
1263    #[test]
1264    fn test_memo_text_accepts_max_length_and_rejects_over_long() {
1265        let max = "x".repeat(28);
1266        assert!(memo_text(&max).is_ok());
1267
1268        let too_long = "x".repeat(29);
1269        let err = memo_text(&too_long).unwrap_err();
1270        assert!(matches!(err, DecodeError::Extraction(msg) if msg.contains("28 bytes")));
1271    }
1272
1273    #[test]
1274    fn test_build_upload_wasm_tx_produces_valid_xdr() {
1275        let params = UploadWasmParams {
1276            source_account: TEST_SOURCE.to_string(),
1277            sequence: 1,
1278            fee: 100,
1279            wasm_bytes: vec![0x00, 0x61, 0x73, 0x6d], // Minimal WASM magic
1280        };
1281
1282        let envelope = build_upload_wasm_tx(&params).unwrap();
1283        assert!(!envelope.is_empty());
1284
1285        // Parse back and verify structure
1286        let raw = STANDARD.decode(&envelope).unwrap();
1287        let mut cursor = std::io::Cursor::new(&raw);
1288        let mut l = stellar_xdr::Limited::new(&mut cursor, stellar_xdr::Limits::none());
1289        let env = TransactionEnvelope::read_xdr(&mut l).unwrap();
1290
1291        match env {
1292            TransactionEnvelope::Tx(v1) => {
1293                assert_eq!(v1.tx.fee, 100);
1294                assert_eq!(v1.tx.seq_num.0, 1);
1295            }
1296            _ => panic!("Expected V1 envelope"),
1297        }
1298    }
1299
1300    #[test]
1301    fn test_build_create_contract_tx_produces_valid_xdr() {
1302        let wasm_hash = [1u8; 32];
1303        let salt = [2u8; 20];
1304
1305        let params = CreateContractParams {
1306            source_account: TEST_SOURCE.to_string(),
1307            sequence: 2,
1308            fee: 100,
1309            wasm_hash,
1310            deployer_address: TEST_SOURCE.to_string(),
1311            salt,
1312        };
1313
1314        let envelope = build_create_contract_tx(&params).unwrap();
1315        assert!(!envelope.is_empty());
1316
1317        // Parse back and verify structure
1318        let raw = STANDARD.decode(&envelope).unwrap();
1319        let mut cursor = std::io::Cursor::new(&raw);
1320        let mut l = stellar_xdr::Limited::new(&mut cursor, stellar_xdr::Limits::none());
1321        let env = TransactionEnvelope::read_xdr(&mut l).unwrap();
1322
1323        match env {
1324            TransactionEnvelope::Tx(v1) => {
1325                assert_eq!(v1.tx.fee, 100);
1326                assert_eq!(v1.tx.seq_num.0, 2);
1327            }
1328            _ => panic!("Expected V1 envelope"),
1329        }
1330    }
1331
1332    #[test]
1333    fn test_derive_contract_id_matches_stellar_formula() {
1334        // Deterministic test vector for Stellar contract ID derivation:
1335        // SHA-256(XDR(HashIdPreimage::ContractId(HashIdPreimageContractId {
1336        //     network_id, contract_id_preimage: ContractIdPreimage::Address(...)
1337        // })))
1338        //
1339        // Reference: soroban-env-host src/host/lifecycle.rs + src/test/lifecycle.rs
1340        //   let full_id_preimage = HashIdPreimage::ContractId(HashIdPreimageContractId {
1341        //       network_id, contract_id_preimage });
1342        //   let contract_id = sha256_hash_id_preimage(full_id_preimage);
1343        //
1344        // Network: "Test SDF Network ; September 2015"
1345        // Source: GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF (all zeros)
1346        // Salt (20 bytes): 0x03 * 20, left-padded to 32 bytes
1347        //
1348        // Expected: e52f714abcb6555da317cb8d4265ffce88401c06e40e60c2d1f721445f683c3c
1349        let mut network_id = [0u8; 32];
1350        network_id.copy_from_slice(
1351            &hex::decode("cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd472")
1352                .unwrap(),
1353        );
1354
1355        let source = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
1356        let salt = [3u8; 20];
1357        let wasm_hash = [4u8; 32];
1358
1359        let result = derive_contract_id(&network_id, source, &salt, &wasm_hash);
1360        assert!(result.is_ok());
1361
1362        let contract_id = result.unwrap();
1363        assert_eq!(
1364            contract_id,
1365            "e52f714abcb6555da317cb8d4265ffce88401c06e40e60c2d1f721445f683c3c"
1366        );
1367    }
1368
1369    #[test]
1370    fn test_derive_contract_id_different_networks_produce_different_ids() {
1371        // Same inputs but different network should produce different contract ID
1372        let mut testnet_network_id = [0u8; 32];
1373        testnet_network_id.copy_from_slice(
1374            &hex::decode("cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd472")
1375                .unwrap(),
1376        );
1377
1378        let mut mainnet_network_id = [0u8; 32];
1379        mainnet_network_id.copy_from_slice(
1380            &hex::decode("7ac33997544e3264d040315e569b3cd71dbbc6eb9a5aa6f2b7ecf1df4b49e5e1")
1381                .unwrap(),
1382        );
1383
1384        let source = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
1385        let salt = [3u8; 20];
1386        let wasm_hash = [4u8; 32];
1387
1388        let testnet_id =
1389            derive_contract_id(&testnet_network_id, source, &salt, &wasm_hash).unwrap();
1390        let mainnet_id =
1391            derive_contract_id(&mainnet_network_id, source, &salt, &wasm_hash).unwrap();
1392
1393        assert_ne!(testnet_id, mainnet_id);
1394    }
1395
1396    #[test]
1397    fn test_build_upload_wasm_tx_with_data() {
1398        let params = UploadWasmParams {
1399            source_account: TEST_SOURCE.to_string(),
1400            sequence: 1,
1401            fee: 50_100, // inclusion_fee + resource_fee
1402            wasm_bytes: vec![0x00, 0x61, 0x73, 0x6d],
1403        };
1404
1405        let soroban_data = realistic_soroban_transaction_data();
1406        let envelope = build_upload_wasm_tx_with_data(&params, soroban_data).unwrap();
1407        assert!(!envelope.is_empty());
1408
1409        // Parse back and verify it has V1 extension with realistic data
1410        let raw = STANDARD.decode(&envelope).unwrap();
1411        let mut cursor = std::io::Cursor::new(&raw);
1412        let mut l = stellar_xdr::Limited::new(&mut cursor, stellar_xdr::Limits::none());
1413        let env = TransactionEnvelope::read_xdr(&mut l).unwrap();
1414
1415        match env {
1416            TransactionEnvelope::Tx(v1) => {
1417                assert_eq!(v1.tx.fee, 50_100);
1418                match v1.tx.ext {
1419                    TransactionExt::V1(data) => {
1420                        assert_eq!(data.resource_fee, 50_000);
1421                        assert_eq!(data.resources.instructions, 100_000_000);
1422                        assert_eq!(data.resources.disk_read_bytes, 100_000);
1423                        assert_eq!(data.resources.write_bytes, 100_000);
1424                        assert_eq!(data.resources.footprint.read_only.len(), 1);
1425                    }
1426                    _ => panic!("Expected V1 extension"),
1427                }
1428            }
1429            _ => panic!("Expected V1 envelope"),
1430        }
1431    }
1432
1433    #[test]
1434    fn test_build_create_contract_tx_with_data() {
1435        let wasm_hash = [1u8; 32];
1436        let salt = [2u8; 20];
1437
1438        let params = CreateContractParams {
1439            source_account: TEST_SOURCE.to_string(),
1440            sequence: 2,
1441            fee: 50_100,
1442            wasm_hash,
1443            deployer_address: TEST_SOURCE.to_string(),
1444            salt,
1445        };
1446
1447        let soroban_data = realistic_soroban_transaction_data();
1448        let envelope = build_create_contract_tx_with_data(&params, soroban_data).unwrap();
1449        assert!(!envelope.is_empty());
1450
1451        // Parse back and verify it has V1 extension with realistic data
1452        let raw = STANDARD.decode(&envelope).unwrap();
1453        let mut cursor = std::io::Cursor::new(&raw);
1454        let mut l = stellar_xdr::Limited::new(&mut cursor, stellar_xdr::Limits::none());
1455        let env = TransactionEnvelope::read_xdr(&mut l).unwrap();
1456
1457        match env {
1458            TransactionEnvelope::Tx(v1) => {
1459                assert_eq!(v1.tx.fee, 50_100);
1460                match v1.tx.ext {
1461                    TransactionExt::V1(data) => {
1462                        assert_eq!(data.resource_fee, 50_000);
1463                        assert_eq!(data.resources.instructions, 100_000_000);
1464                    }
1465                    _ => panic!("Expected V1 extension"),
1466                }
1467            }
1468            _ => panic!("Expected V1 envelope"),
1469        }
1470    }
1471
1472    #[test]
1473    fn test_parse_soroban_transaction_data() {
1474        // Create a valid SorobanTransactionData and encode it
1475        let data = realistic_soroban_transaction_data();
1476        let mut buf = Vec::new();
1477        let mut l = stellar_xdr::Limited::new(&mut buf, stellar_xdr::Limits::none());
1478        data.write_xdr(&mut l).unwrap();
1479        let encoded = STANDARD.encode(&buf);
1480
1481        // Parse it back
1482        let result = parse_soroban_transaction_data(&encoded);
1483        assert!(result.is_ok());
1484        let parsed = result.unwrap();
1485        assert_eq!(parsed.resource_fee, 50_000);
1486        assert_eq!(parsed.resources.instructions, 100_000_000);
1487        assert_eq!(parsed.resources.footprint.read_only.len(), 1);
1488    }
1489
1490    #[test]
1491    fn test_build_upload_wasm_tx_with_auth() {
1492        let params = UploadWasmParams {
1493            source_account: TEST_SOURCE.to_string(),
1494            sequence: 1,
1495            fee: 50_100,
1496            wasm_bytes: vec![0x00, 0x61, 0x73, 0x6d],
1497        };
1498
1499        let soroban_data = realistic_soroban_transaction_data();
1500        let auth_entries = vec![test_auth_entry()];
1501        let envelope =
1502            build_upload_wasm_tx_with_data_and_auth(&params, soroban_data, auth_entries).unwrap();
1503        assert!(!envelope.is_empty());
1504
1505        // Parse back and verify it has V1 extension and auth entries
1506        let raw = STANDARD.decode(&envelope).unwrap();
1507        let mut cursor = std::io::Cursor::new(&raw);
1508        let mut l = stellar_xdr::Limited::new(&mut cursor, stellar_xdr::Limits::none());
1509        let env = TransactionEnvelope::read_xdr(&mut l).unwrap();
1510
1511        match env {
1512            TransactionEnvelope::Tx(v1) => match v1.tx.ext {
1513                TransactionExt::V1(data) => {
1514                    assert_eq!(data.resource_fee, 50_000);
1515                    // Verify auth entries are present in the operation
1516                    let op = &v1.tx.operations[0];
1517                    match &op.body {
1518                        OperationBody::InvokeHostFunction(host_fn) => {
1519                            assert_eq!(host_fn.auth.len(), 1);
1520                            assert_eq!(
1521                                host_fn.auth[0].credentials,
1522                                SorobanCredentials::SourceAccount
1523                            );
1524                        }
1525                        _ => panic!("Expected InvokeHostFunction"),
1526                    }
1527                }
1528                _ => panic!("Expected V1 extension"),
1529            },
1530            _ => panic!("Expected V1 envelope"),
1531        }
1532    }
1533
1534    #[test]
1535    fn test_build_create_contract_tx_with_auth() {
1536        let wasm_hash = [1u8; 32];
1537        let salt = [2u8; 20];
1538
1539        let params = CreateContractParams {
1540            source_account: TEST_SOURCE.to_string(),
1541            sequence: 2,
1542            fee: 50_100,
1543            wasm_hash,
1544            deployer_address: TEST_SOURCE.to_string(),
1545            salt,
1546        };
1547
1548        let soroban_data = realistic_soroban_transaction_data();
1549        let auth_entries = vec![test_auth_entry()];
1550        let envelope =
1551            build_create_contract_tx_with_data_and_auth(&params, soroban_data, auth_entries)
1552                .unwrap();
1553        assert!(!envelope.is_empty());
1554
1555        // Parse back and verify it has V1 extension and auth entries
1556        let raw = STANDARD.decode(&envelope).unwrap();
1557        let mut cursor = std::io::Cursor::new(&raw);
1558        let mut l = stellar_xdr::Limited::new(&mut cursor, stellar_xdr::Limits::none());
1559        let env = TransactionEnvelope::read_xdr(&mut l).unwrap();
1560
1561        match env {
1562            TransactionEnvelope::Tx(v1) => match v1.tx.ext {
1563                TransactionExt::V1(data) => {
1564                    assert_eq!(data.resource_fee, 50_000);
1565                    // Verify auth entries are present in the operation
1566                    let op = &v1.tx.operations[0];
1567                    match &op.body {
1568                        OperationBody::InvokeHostFunction(host_fn) => {
1569                            assert_eq!(host_fn.auth.len(), 1);
1570                            assert_eq!(
1571                                host_fn.auth[0].credentials,
1572                                SorobanCredentials::SourceAccount
1573                            );
1574                        }
1575                        _ => panic!("Expected InvokeHostFunction"),
1576                    }
1577                }
1578                _ => panic!("Expected V1 extension"),
1579            },
1580            _ => panic!("Expected V1 envelope"),
1581        }
1582    }
1583
1584    #[test]
1585    fn test_parse_invalid_auth_entry() {
1586        let result = parse_soroban_authorization_entry("invalid-base64!!!");
1587        assert!(result.is_err());
1588    }
1589
1590    #[test]
1591    fn test_parse_empty_auth_list() {
1592        let auth_list: Vec<String> = vec![];
1593        let result = parse_soroban_authorization_entries(&auth_list);
1594        assert!(result.is_ok());
1595        assert_eq!(result.unwrap().len(), 0);
1596    }
1597
1598    #[test]
1599    fn test_build_create_contract_v2_tx_produces_valid_xdr() {
1600        use crate::scval_to_base64;
1601        let wasm_hash = [1u8; 32];
1602        let salt = [2u8; 20];
1603
1604        let arg_b64 = scval_to_base64(&ScVal::U32(42)).unwrap();
1605        let params = CreateContractV2Params {
1606            source_account: TEST_SOURCE.to_string(),
1607            sequence: 2,
1608            fee: 100,
1609            wasm_hash,
1610            deployer_address: TEST_SOURCE.to_string(),
1611            salt,
1612            constructor_args: vec![arg_b64],
1613        };
1614
1615        let envelope = build_create_contract_v2_tx(&params).unwrap();
1616        assert!(!envelope.is_empty());
1617
1618        let raw = STANDARD.decode(&envelope).unwrap();
1619        let mut cursor = std::io::Cursor::new(&raw);
1620        let mut l = stellar_xdr::Limited::new(&mut cursor, stellar_xdr::Limits::none());
1621        let env = TransactionEnvelope::read_xdr(&mut l).unwrap();
1622
1623        match env {
1624            TransactionEnvelope::Tx(v1) => {
1625                assert_eq!(v1.tx.fee, 100);
1626                assert_eq!(v1.tx.seq_num.0, 2);
1627                let op = &v1.tx.operations[0];
1628                match &op.body {
1629                    OperationBody::InvokeHostFunction(host_fn) => match &host_fn.host_function {
1630                        HostFunction::CreateContractV2(args) => {
1631                            assert_eq!(args.constructor_args.len(), 1);
1632                            assert_eq!(args.constructor_args[0], ScVal::U32(42));
1633                            assert_eq!(args.executable, ContractExecutable::Wasm(Hash(wasm_hash)));
1634                        }
1635                        _ => panic!("Expected CreateContractV2 host function"),
1636                    },
1637                    _ => panic!("Expected InvokeHostFunction operation"),
1638                }
1639            }
1640            _ => panic!("Expected V1 envelope"),
1641        }
1642    }
1643
1644    #[test]
1645    fn test_build_create_contract_v2_tx_with_data() {
1646        use crate::scval_to_base64;
1647        let wasm_hash = [1u8; 32];
1648        let salt = [2u8; 20];
1649
1650        let arg_b64 = scval_to_base64(&ScVal::I32(-7)).unwrap();
1651        let params = CreateContractV2Params {
1652            source_account: TEST_SOURCE.to_string(),
1653            sequence: 3,
1654            fee: 100,
1655            wasm_hash,
1656            deployer_address: TEST_SOURCE.to_string(),
1657            salt,
1658            constructor_args: vec![arg_b64],
1659        };
1660
1661        let soroban_data = realistic_soroban_transaction_data();
1662        let envelope = build_create_contract_v2_tx_with_data(&params, soroban_data).unwrap();
1663        assert!(!envelope.is_empty());
1664
1665        let raw = STANDARD.decode(&envelope).unwrap();
1666        let mut cursor = std::io::Cursor::new(&raw);
1667        let mut l = stellar_xdr::Limited::new(&mut cursor, stellar_xdr::Limits::none());
1668        let env = TransactionEnvelope::read_xdr(&mut l).unwrap();
1669
1670        match env {
1671            TransactionEnvelope::Tx(v1) => match v1.tx.ext {
1672                TransactionExt::V1(data) => {
1673                    assert_eq!(data.resource_fee, 50_000);
1674                    let op = &v1.tx.operations[0];
1675                    match &op.body {
1676                        OperationBody::InvokeHostFunction(host_fn) => {
1677                            match &host_fn.host_function {
1678                                HostFunction::CreateContractV2(args) => {
1679                                    assert_eq!(args.constructor_args.len(), 1);
1680                                    assert_eq!(args.constructor_args[0], ScVal::I32(-7));
1681                                }
1682                                _ => panic!("Expected CreateContractV2"),
1683                            }
1684                        }
1685                        _ => panic!("Expected InvokeHostFunction"),
1686                    }
1687                }
1688                _ => panic!("Expected V1 extension"),
1689            },
1690            _ => panic!("Expected V1 envelope"),
1691        }
1692    }
1693
1694    #[test]
1695    fn test_build_create_contract_v2_tx_with_auth() {
1696        use crate::scval_to_base64;
1697        let wasm_hash = [1u8; 32];
1698        let salt = [2u8; 20];
1699
1700        let arg_b64 = scval_to_base64(&ScVal::U32(99)).unwrap();
1701        let params = CreateContractV2Params {
1702            source_account: TEST_SOURCE.to_string(),
1703            sequence: 4,
1704            fee: 50_100,
1705            wasm_hash,
1706            deployer_address: TEST_SOURCE.to_string(),
1707            salt,
1708            constructor_args: vec![arg_b64],
1709        };
1710
1711        let soroban_data = realistic_soroban_transaction_data();
1712        let auth_entries = vec![test_auth_entry()];
1713        let envelope =
1714            build_create_contract_v2_tx_with_data_and_auth(&params, soroban_data, auth_entries)
1715                .unwrap();
1716        assert!(!envelope.is_empty());
1717
1718        let raw = STANDARD.decode(&envelope).unwrap();
1719        let mut cursor = std::io::Cursor::new(&raw);
1720        let mut l = stellar_xdr::Limited::new(&mut cursor, stellar_xdr::Limits::none());
1721        let env = TransactionEnvelope::read_xdr(&mut l).unwrap();
1722
1723        match env {
1724            TransactionEnvelope::Tx(v1) => {
1725                let op = &v1.tx.operations[0];
1726                match &op.body {
1727                    OperationBody::InvokeHostFunction(host_fn) => {
1728                        assert_eq!(host_fn.auth.len(), 1);
1729                        assert_eq!(
1730                            host_fn.auth[0].credentials,
1731                            SorobanCredentials::SourceAccount
1732                        );
1733                        match &host_fn.host_function {
1734                            HostFunction::CreateContractV2(args) => {
1735                                assert_eq!(args.constructor_args.len(), 1);
1736                                assert_eq!(args.constructor_args[0], ScVal::U32(99));
1737                            }
1738                            _ => panic!("Expected CreateContractV2"),
1739                        }
1740                    }
1741                    _ => panic!("Expected InvokeHostFunction"),
1742                }
1743            }
1744            _ => panic!("Expected V1 envelope"),
1745        }
1746    }
1747
1748    #[test]
1749    fn test_contract_id_derivation_identical_between_v1_and_v2() {
1750        use crate::scval_to_base64;
1751
1752        let network_id = [42u8; 32];
1753        let deployer = TEST_SOURCE;
1754        let salt = [7u8; 20];
1755        let wasm_hash = [99u8; 32];
1756
1757        let v1_params = CreateContractParams {
1758            source_account: deployer.to_string(),
1759            sequence: 100,
1760            fee: 50_000,
1761            wasm_hash,
1762            deployer_address: deployer.to_string(),
1763            salt,
1764        };
1765        let v1_envelope = build_create_contract_tx(&v1_params).unwrap();
1766
1767        let arg_b64 = scval_to_base64(&ScVal::U32(42)).unwrap();
1768        let v2_params = CreateContractV2Params {
1769            source_account: deployer.to_string(),
1770            sequence: 100,
1771            fee: 50_000,
1772            wasm_hash,
1773            deployer_address: deployer.to_string(),
1774            salt,
1775            constructor_args: vec![arg_b64],
1776        };
1777        let v2_envelope = build_create_contract_v2_tx(&v2_params).unwrap();
1778
1779        let decode_env = |b64: &str| -> TransactionEnvelope {
1780            let raw = STANDARD.decode(b64).unwrap();
1781            let mut cursor = std::io::Cursor::new(&raw);
1782            let mut l = stellar_xdr::Limited::new(&mut cursor, stellar_xdr::Limits::none());
1783            TransactionEnvelope::read_xdr(&mut l).unwrap()
1784        };
1785
1786        let v1_preimage = match decode_env(&v1_envelope) {
1787            TransactionEnvelope::Tx(v1) => match &v1.tx.operations[0].body {
1788                OperationBody::InvokeHostFunction(hf) => match &hf.host_function {
1789                    HostFunction::CreateContract(args) => args.contract_id_preimage.clone(),
1790                    _ => panic!("Expected CreateContract in V1"),
1791                },
1792                _ => panic!("Expected InvokeHostFunction"),
1793            },
1794            _ => panic!("Expected V1 envelope"),
1795        };
1796
1797        let v2_preimage = match decode_env(&v2_envelope) {
1798            TransactionEnvelope::Tx(v1) => match &v1.tx.operations[0].body {
1799                OperationBody::InvokeHostFunction(hf) => match &hf.host_function {
1800                    HostFunction::CreateContractV2(args) => args.contract_id_preimage.clone(),
1801                    _ => panic!("Expected CreateContractV2 in V2"),
1802                },
1803                _ => panic!("Expected InvokeHostFunction"),
1804            },
1805            _ => panic!("Expected V1 envelope"),
1806        };
1807
1808        assert_eq!(v1_preimage, v2_preimage);
1809
1810        // Contract ID derivation is identical whether deploying via V1 or V2
1811        let id1 = derive_contract_id(&network_id, deployer, &salt, &wasm_hash).unwrap();
1812        let id2 = derive_contract_id(&network_id, deployer, &salt, &wasm_hash).unwrap();
1813        assert_eq!(id1, id2);
1814    }
1815
1816    #[test]
1817    fn test_auth_entry_create_contract_v2_host_fn() {
1818        use stellar_xdr::{
1819            SorobanAuthorizedFunction, SorobanAuthorizedInvocation, SorobanCredentials,
1820        };
1821
1822        let deployer = decode_account_id(TEST_SOURCE).unwrap();
1823        let preimage = ContractIdPreimage::Address(ContractIdPreimageFromAddress {
1824            address: ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(
1825                match deployer.0 {
1826                    PublicKey::PublicKeyTypeEd25519(u) => u,
1827                },
1828            ))),
1829            salt: Uint256([0; 32]),
1830        });
1831
1832        let constructor_args = VecM::try_from(vec![ScVal::U32(100)]).unwrap();
1833
1834        let auth_entry = SorobanAuthorizationEntry {
1835            credentials: SorobanCredentials::SourceAccount,
1836            root_invocation: SorobanAuthorizedInvocation {
1837                function: SorobanAuthorizedFunction::CreateContractV2HostFn(CreateContractArgsV2 {
1838                    contract_id_preimage: preimage,
1839                    executable: ContractExecutable::Wasm(Hash([1; 32])),
1840                    constructor_args,
1841                }),
1842                sub_invocations: VecM::default(),
1843            },
1844        };
1845
1846        let mut buf = Vec::new();
1847        let mut l = stellar_xdr::Limited::new(&mut buf, stellar_xdr::Limits::none());
1848        auth_entry.write_xdr(&mut l).unwrap();
1849        let b64 = STANDARD.encode(&buf);
1850
1851        let parsed = parse_soroban_authorization_entry(&b64).unwrap();
1852        match parsed.root_invocation.function {
1853            SorobanAuthorizedFunction::CreateContractV2HostFn(args) => {
1854                assert_eq!(args.executable, ContractExecutable::Wasm(Hash([1; 32])));
1855                assert_eq!(args.constructor_args.len(), 1);
1856                assert_eq!(args.constructor_args[0], ScVal::U32(100));
1857            }
1858            _ => panic!("Expected CreateContractV2HostFn"),
1859        }
1860    }
1861}
1862
1863#[cfg(test)]
1864mod extract_tests {
1865    use crate::extract_contract_data_value;
1866    use base64::Engine;
1867    use stellar_xdr::{
1868        ContractDataDurability, ContractDataEntry, ContractId, ExtensionPoint, Hash, LedgerEntry,
1869        LedgerEntryData, LedgerEntryExt, Limited, Limits, ScAddress, ScVal, WriteXdr,
1870    };
1871
1872    #[test]
1873    fn test_extract_contract_data_value_returns_val() {
1874        let entry = LedgerEntry {
1875            last_modified_ledger_seq: 1,
1876            data: LedgerEntryData::ContractData(ContractDataEntry {
1877                ext: ExtensionPoint::V0,
1878                contract: ScAddress::Contract(ContractId(Hash([0; 32]))),
1879                key: ScVal::U32(1),
1880                durability: ContractDataDurability::Persistent,
1881                val: ScVal::U32(42),
1882            }),
1883            ext: LedgerEntryExt::V0,
1884        };
1885        let mut buf = Vec::new();
1886        let mut l = Limited::new(&mut buf, Limits::none());
1887        entry.write_xdr(&mut l).unwrap();
1888        let b64 = base64::engine::general_purpose::STANDARD.encode(&buf);
1889
1890        let cd = extract_contract_data_value(&b64).unwrap();
1891        assert!(matches!(cd.val, ScVal::U32(42)));
1892        assert_eq!(cd.key, ScVal::U32(1));
1893        assert_eq!(cd.durability, ContractDataDurability::Persistent);
1894    }
1895
1896    #[test]
1897    fn test_extract_contract_data_value_rejects_non_contract_data() {
1898        let result = extract_contract_data_value("AAAABAAAAAE=");
1899        assert!(result.is_err());
1900    }
1901}
1902
1903#[cfg(test)]
1904mod extend_tests {
1905    use super::*;
1906    use crate::builder::{
1907        build_extend_footprint_tx, build_extend_footprint_tx_with_data,
1908        realistic_soroban_transaction_data, ExtendFootprintParams,
1909    };
1910    use stellar_xdr::{
1911        ContractDataDurability, Hash, LedgerKey, LedgerKeyContractData, Limited, Limits,
1912        OperationBody, ScAddress, ScVal, TransactionEnvelope, TransactionExt, WriteXdr,
1913    };
1914
1915    #[test]
1916    fn test_build_extend_footprint_tx_round_trip() {
1917        let k = LedgerKey::ContractData(LedgerKeyContractData {
1918            contract: ScAddress::Contract(ContractId(Hash([0; 32]))),
1919            key: ScVal::LedgerKeyContractInstance,
1920            durability: ContractDataDurability::Persistent,
1921        });
1922        let mut buf = Vec::new();
1923        let mut l = Limited::new(&mut buf, Limits::none());
1924        k.write_xdr(&mut l).unwrap();
1925        let key = STANDARD.encode(&buf);
1926
1927        let params = ExtendFootprintParams {
1928            source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".into(),
1929            sequence: 7,
1930            fee: 100,
1931            extend_to: 100_000,
1932            footprint_keys: vec![key],
1933        };
1934        let envelope = build_extend_footprint_tx(&params).unwrap();
1935        let raw = STANDARD.decode(&envelope).unwrap();
1936        let mut cursor = std::io::Cursor::new(&raw);
1937        let mut l = Limited::new(&mut cursor, Limits::none());
1938        let env = TransactionEnvelope::read_xdr(&mut l).unwrap();
1939        match env {
1940            TransactionEnvelope::Tx(v1) => {
1941                assert_eq!(v1.tx.seq_num.0, 7);
1942                match &v1.tx.operations[0].body {
1943                    OperationBody::ExtendFootprintTtl(op) => {
1944                        assert_eq!(op.extend_to, 100_000);
1945                    }
1946                    other => panic!("expected ExtendFootprintTtl, got {other:?}"),
1947                }
1948                match v1.tx.ext {
1949                    TransactionExt::V1(data) => {
1950                        assert_eq!(data.resources.footprint.read_only.len(), 1);
1951                        assert!(data.resources.footprint.read_write.is_empty());
1952                    }
1953                    other => panic!("expected V1 extension, got {other:?}"),
1954                }
1955            }
1956            _ => panic!("Expected V1 envelope"),
1957        }
1958    }
1959
1960    #[test]
1961    fn test_build_extend_footprint_tx_rejects_zero_ledgers() {
1962        let k = LedgerKey::ContractData(LedgerKeyContractData {
1963            contract: ScAddress::Contract(ContractId(Hash([0; 32]))),
1964            key: ScVal::LedgerKeyContractInstance,
1965            durability: ContractDataDurability::Persistent,
1966        });
1967        let mut buf = Vec::new();
1968        let mut l = Limited::new(&mut buf, Limits::none());
1969        k.write_xdr(&mut l).unwrap();
1970        let key = STANDARD.encode(&buf);
1971
1972        let params = ExtendFootprintParams {
1973            source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".into(),
1974            sequence: 1,
1975            fee: 100,
1976            extend_to: 0,
1977            footprint_keys: vec![key],
1978        };
1979        let err = build_extend_footprint_tx(&params).unwrap_err();
1980        assert!(err.to_string().contains("greater than 0"));
1981    }
1982
1983    #[test]
1984    fn test_build_extend_footprint_tx_with_data_preserves_simulation_resources() {
1985        let k = LedgerKey::ContractData(LedgerKeyContractData {
1986            contract: ScAddress::Contract(ContractId(Hash([0; 32]))),
1987            key: ScVal::LedgerKeyContractInstance,
1988            durability: ContractDataDurability::Persistent,
1989        });
1990        let mut buf = Vec::new();
1991        let mut l = Limited::new(&mut buf, Limits::none());
1992        k.write_xdr(&mut l).unwrap();
1993        let key = STANDARD.encode(&buf);
1994
1995        let params = ExtendFootprintParams {
1996            source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".into(),
1997            sequence: 2,
1998            fee: 100,
1999            extend_to: 50_000,
2000            footprint_keys: vec![key],
2001        };
2002        let data = realistic_soroban_transaction_data();
2003        let envelope = build_extend_footprint_tx_with_data(&params, data).unwrap();
2004        let raw = STANDARD.decode(&envelope).unwrap();
2005        let mut cursor = std::io::Cursor::new(&raw);
2006        let mut l = Limited::new(&mut cursor, Limits::none());
2007        let env = TransactionEnvelope::read_xdr(&mut l).unwrap();
2008        match env {
2009            TransactionEnvelope::Tx(v1) => match v1.tx.ext {
2010                TransactionExt::V1(data) => {
2011                    assert_eq!(data.resource_fee, 50_000);
2012                    assert_eq!(data.resources.instructions, 100_000_000);
2013                }
2014                _ => panic!("Expected V1 extension"),
2015            },
2016            _ => panic!("Expected V1 envelope"),
2017        }
2018    }
2019}
2020
2021#[cfg(test)]
2022mod restore_tests {
2023    use super::*;
2024    use crate::builder::{build_restore_footprint_tx, RestoreFootprintParams};
2025    use stellar_xdr::{
2026        ContractDataDurability, Hash, LedgerFootprint, LedgerKey, LedgerKeyContractData, Limited,
2027        Limits, OperationBody, ScAddress, ScVal, SorobanResources, SorobanTransactionData,
2028        SorobanTransactionDataExt, TransactionEnvelope, TransactionExt, VecM,
2029    };
2030
2031    fn create_test_soroban_data() -> SorobanTransactionData {
2032        let k = LedgerKey::ContractData(LedgerKeyContractData {
2033            contract: ScAddress::Contract(ContractId(Hash([1; 32]))),
2034            key: ScVal::LedgerKeyContractInstance,
2035            durability: ContractDataDurability::Persistent,
2036        });
2037        SorobanTransactionData {
2038            ext: SorobanTransactionDataExt::V0,
2039            resources: SorobanResources {
2040                footprint: LedgerFootprint {
2041                    read_only: VecM::try_from(vec![k]).unwrap(),
2042                    read_write: VecM::default(),
2043                },
2044                instructions: 1000,
2045                disk_read_bytes: 500,
2046                write_bytes: 200,
2047            },
2048            resource_fee: 10000,
2049        }
2050    }
2051
2052    #[test]
2053    fn test_build_restore_footprint_tx_round_trip() {
2054        let soroban_data = create_test_soroban_data();
2055        let params = RestoreFootprintParams {
2056            source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".into(),
2057            sequence: 10,
2058            fee: 10_200,
2059            soroban_data,
2060        };
2061        let envelope = build_restore_footprint_tx(&params).unwrap();
2062        let raw = STANDARD.decode(&envelope).unwrap();
2063        let mut cursor = std::io::Cursor::new(&raw);
2064        let mut l = Limited::new(&mut cursor, Limits::none());
2065        let env = TransactionEnvelope::read_xdr(&mut l).unwrap();
2066        match env {
2067            TransactionEnvelope::Tx(v1) => {
2068                assert_eq!(v1.tx.seq_num.0, 10);
2069                assert_eq!(v1.tx.fee, 10_200);
2070                match &v1.tx.operations[0].body {
2071                    OperationBody::RestoreFootprint(_) => {
2072                        // RestoreFootprintOp only has ext field (V0)
2073                    }
2074                    other => panic!("expected RestoreFootprint, got {other:?}"),
2075                }
2076                // Verify the SorobanTransactionData from preamble is preserved
2077                match &v1.tx.ext {
2078                    TransactionExt::V1(soroban_data) => {
2079                        assert_eq!(soroban_data.resource_fee, 10000);
2080                        assert_eq!(soroban_data.resources.instructions, 1000);
2081                        assert!(!soroban_data.resources.footprint.read_only.is_empty());
2082                    }
2083                    other => panic!("expected V1 extension, got {other:?}"),
2084                }
2085            }
2086            other => panic!("expected v1 envelope, got {other:?}"),
2087        }
2088    }
2089
2090    #[test]
2091    fn test_build_restore_footprint_tx_preserves_preamble_footprint() {
2092        let soroban_data = create_test_soroban_data();
2093        let original_footprint_keys = soroban_data.resources.footprint.read_only.clone();
2094
2095        let params = RestoreFootprintParams {
2096            source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".into(),
2097            sequence: 11,
2098            fee: 10_300,
2099            soroban_data,
2100        };
2101        let envelope = build_restore_footprint_tx(&params).unwrap();
2102        let raw = STANDARD.decode(&envelope).unwrap();
2103        let mut cursor = std::io::Cursor::new(&raw);
2104        let mut l = Limited::new(&mut cursor, Limits::none());
2105        let env = TransactionEnvelope::read_xdr(&mut l).unwrap();
2106        match env {
2107            TransactionEnvelope::Tx(v1) => match &v1.tx.ext {
2108                TransactionExt::V1(soroban_data) => {
2109                    assert_eq!(
2110                        soroban_data.resources.footprint.read_only,
2111                        original_footprint_keys
2112                    );
2113                }
2114                other => panic!("expected V1 extension, got {other:?}"),
2115            },
2116            other => panic!("expected v1 envelope, got {other:?}"),
2117        }
2118    }
2119
2120    #[test]
2121    fn test_build_restore_footprint_tx_fee_floor_enforcement() {
2122        let soroban_data = create_test_soroban_data();
2123        let min_fee = soroban_data.resource_fee as u32;
2124
2125        // Test with fee exactly at minimum
2126        let params = RestoreFootprintParams {
2127            source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".into(),
2128            sequence: 12,
2129            fee: min_fee,
2130            soroban_data: soroban_data.clone(),
2131        };
2132        assert!(build_restore_footprint_tx(&params).is_ok());
2133
2134        // Test with fee above minimum
2135        let params = RestoreFootprintParams {
2136            source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".into(),
2137            sequence: 13,
2138            fee: min_fee + 100,
2139            soroban_data: soroban_data.clone(),
2140        };
2141        assert!(build_restore_footprint_tx(&params).is_ok());
2142
2143        // A fee below the preamble resource fee is rejected, not submitted.
2144        let params = RestoreFootprintParams {
2145            source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".into(),
2146            sequence: 14,
2147            fee: min_fee - 1,
2148            soroban_data,
2149        };
2150        let err = build_restore_footprint_tx(&params).unwrap_err();
2151        assert!(err.to_string().contains("below the preamble resource fee"));
2152    }
2153
2154    #[test]
2155    fn test_build_restore_footprint_tx_rejects_empty_footprint() {
2156        let mut soroban_data = create_test_soroban_data();
2157        soroban_data.resources.footprint = LedgerFootprint {
2158            read_only: VecM::default(),
2159            read_write: VecM::default(),
2160        };
2161        let params = RestoreFootprintParams {
2162            source_account: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".into(),
2163            sequence: 15,
2164            fee: 20_000,
2165            soroban_data,
2166        };
2167        let err = build_restore_footprint_tx(&params).unwrap_err();
2168        assert!(err.to_string().contains("footprint is empty"));
2169    }
2170}