Skip to main content

sdkt_core/
tx_builder.rs

1use stellar_strkey::Strkey;
2use stellar_xdr::{
3    ContractId, Error as XdrError, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp,
4    Limits, Memo, MuxedAccount, Operation, OperationBody, Preconditions, ScAddress, ScSymbol,
5    SequenceNumber, TimeBounds, Transaction, TransactionEnvelope, TransactionExt,
6    TransactionV1Envelope, Uint256, VecM, WriteXdr,
7};
8use thiserror::Error;
9
10#[derive(Debug, Error)]
11pub enum BuilderError {
12    #[error("Invalid source account: {0}")]
13    InvalidSourceAccount(String),
14    #[error("Invalid memo: {0}")]
15    InvalidMemo(String),
16    #[error("XDR encoding error: {0}")]
17    XdrError(#[from] XdrError),
18    #[error("Missing operation")]
19    MissingOperation,
20    #[error("Invalid operation: {0}")]
21    InvalidOperation(String),
22}
23
24/// A builder for constructing Stellar `TransactionEnvelope`s (V1).
25pub struct TxBuilder {
26    source_account: Option<MuxedAccount>,
27    sequence_number: Option<i64>,
28    fee: u32,
29    memo: Memo,
30    preconditions: Preconditions,
31    operations: Vec<Operation>,
32    ext: TransactionExt,
33}
34
35impl Default for TxBuilder {
36    fn default() -> Self {
37        Self {
38            source_account: None,
39            sequence_number: None,
40            fee: 100, // Default minimum fee
41            memo: Memo::None,
42            preconditions: Preconditions::None,
43            operations: Vec::new(),
44            ext: TransactionExt::V0,
45        }
46    }
47}
48
49impl TxBuilder {
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Set the source account from a public key (G...)
55    pub fn source_account(mut self, address: &str) -> Result<Self, BuilderError> {
56        let key = Strkey::from_string(address)
57            .map_err(|e| BuilderError::InvalidSourceAccount(e.to_string()))?;
58
59        if let Strkey::PublicKeyEd25519(pk) = key {
60            self.source_account = Some(MuxedAccount::Ed25519(Uint256(pk.0)));
61            Ok(self)
62        } else {
63            Err(BuilderError::InvalidSourceAccount(
64                "Must be a G... public key".into(),
65            ))
66        }
67    }
68
69    /// Set the sequence number
70    pub fn sequence_number(mut self, seq: i64) -> Self {
71        self.sequence_number = Some(seq);
72        self
73    }
74
75    /// Set the base fee
76    pub fn fee(mut self, fee: u32) -> Self {
77        self.fee = fee;
78        self
79    }
80
81    /// Set a text memo
82    pub fn memo_text(mut self, text: &str) -> Result<Self, BuilderError> {
83        let bytes = text.as_bytes();
84        let vec_m = bytes
85            .try_into()
86            .map_err(|_| BuilderError::InvalidMemo("Text too long".into()))?;
87        self.memo = Memo::Text(vec_m);
88        Ok(self)
89    }
90
91    /// Set an ID memo
92    pub fn memo_id(mut self, id: u64) -> Self {
93        self.memo = Memo::Id(id);
94        self
95    }
96
97    /// Add a pre-built Operation
98    pub fn add_operation(mut self, op: Operation) -> Self {
99        self.operations.push(op);
100        self
101    }
102
103    /// Add an `InvokeHostFunction` operation for a smart-contract call.
104    ///
105    /// This is a convenience that wraps a [`HostFunction::InvokeContract`] op
106    /// without requiring the caller to hand-assemble the XDR [`Operation`].
107    ///
108    /// # Errors
109    ///
110    /// Returns [`BuilderError::InvalidOperation`] if the contract ID (`C...`)
111    /// or function name is invalid.
112    pub fn invoke_contract(
113        mut self,
114        contract_id: &str,
115        function: &str,
116        args: Vec<stellar_xdr::ScVal>,
117    ) -> Result<Self, BuilderError> {
118        let hash = decode_contract_id_direct(contract_id)
119            .map_err(|e| BuilderError::InvalidOperation(format!("contract ID: {e}")))?;
120
121        let sc_symbol: stellar_xdr::StringM<32> = function
122            .as_bytes()
123            .try_into()
124            .map_err(|_| BuilderError::InvalidOperation("function name too long".into()))?;
125        let function_name = ScSymbol(sc_symbol);
126
127        let args_vec: VecM<stellar_xdr::ScVal> = args
128            .try_into()
129            .map_err(|_| BuilderError::InvalidOperation("too many args".into()))?;
130
131        let op = Operation {
132            source_account: None,
133            body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
134                host_function: HostFunction::InvokeContract(InvokeContractArgs {
135                    contract_address: ScAddress::Contract(ContractId(hash)),
136                    function_name,
137                    args: args_vec,
138                }),
139                auth: VecM::default(),
140            }),
141        };
142        self.operations.push(op);
143        Ok(self)
144    }
145
146    /// Set ext to V1 with SorobanTransactionData (for host functions)
147    pub fn set_ext(mut self, ext: TransactionExt) -> Self {
148        self.ext = ext;
149        self
150    }
151
152    /// Set timebounds timeout (from now to `0` means unbounded for now, or we can just leave None)
153    /// Real implementations might take a minTime and maxTime. We provide a basic max_time setter.
154    pub fn max_time(mut self, max_time: u64) -> Self {
155        self.preconditions = Preconditions::Time(TimeBounds {
156            min_time: stellar_xdr::TimePoint(0),
157            max_time: stellar_xdr::TimePoint(max_time),
158        });
159        self
160    }
161
162    /// Build the `TransactionEnvelope` (unsigned).
163    pub fn build(self) -> Result<TransactionEnvelope, BuilderError> {
164        let source_account = self
165            .source_account
166            .ok_or_else(|| BuilderError::InvalidSourceAccount("Missing source account".into()))?;
167        let seq = self
168            .sequence_number
169            .ok_or_else(|| BuilderError::InvalidSourceAccount("Missing sequence number".into()))?;
170
171        if self.operations.is_empty() {
172            return Err(BuilderError::MissingOperation);
173        }
174
175        let ops_vec: VecM<Operation, 100> = self
176            .operations
177            .try_into()
178            .map_err(|_| BuilderError::InvalidOperation("Too many operations".into()))?;
179
180        let tx = Transaction {
181            source_account,
182            fee: self.fee,
183            seq_num: SequenceNumber(seq),
184            cond: self.preconditions,
185            memo: self.memo,
186            operations: ops_vec,
187            ext: self.ext,
188        };
189
190        let env_v1 = TransactionV1Envelope {
191            tx,
192            signatures: VecM::default(), // Empty signatures
193        };
194
195        Ok(TransactionEnvelope::Tx(env_v1))
196    }
197
198    /// Build and encode to base64
199    pub fn build_base64(self) -> Result<String, BuilderError> {
200        let envelope = self.build()?;
201        let b64 = envelope.to_xdr_base64(Limits::none())?;
202        Ok(b64)
203    }
204}
205
206fn decode_contract_id_direct(contract_id: &str) -> Result<Hash, String> {
207    let key = Strkey::from_string(contract_id).map_err(|e| e.to_string())?;
208    match key {
209        Strkey::Contract(c) => Ok(Hash(c.0)),
210        _ => Err("Expected C... contract StrKey".into()),
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use stellar_xdr::{OperationBody, ReadXdr};
218
219    #[test]
220    fn test_builder_missing_source() {
221        let builder = TxBuilder::new().sequence_number(1);
222        assert!(matches!(
223            builder.build(),
224            Err(BuilderError::InvalidSourceAccount(_))
225        ));
226    }
227
228    const TEST_SOURCE: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
229
230    #[test]
231    fn test_builder_missing_operations() {
232        let builder = TxBuilder::new()
233            .source_account(TEST_SOURCE)
234            .unwrap()
235            .sequence_number(1);
236        assert!(matches!(
237            builder.build(),
238            Err(BuilderError::MissingOperation)
239        ));
240    }
241
242    #[test]
243    fn test_builder_invalid_memo() {
244        let res = TxBuilder::new().memo_text(
245            "this text is way too long to fit into a stellar memo which has a 28 char limit",
246        );
247        assert!(matches!(res, Err(BuilderError::InvalidMemo(_))));
248    }
249
250    #[test]
251    fn test_valid_build_and_encode() {
252        let op = Operation {
253            source_account: None,
254            body: OperationBody::BumpSequence(stellar_xdr::BumpSequenceOp {
255                bump_to: SequenceNumber(10),
256            }),
257        };
258
259        let b64 = TxBuilder::new()
260            .source_account(TEST_SOURCE)
261            .unwrap()
262            .sequence_number(123)
263            .fee(150)
264            .memo_id(456)
265            .add_operation(op)
266            .build_base64()
267            .unwrap();
268
269        // Valid base64 output that decodes back
270        let envelope = TransactionEnvelope::from_xdr_base64(b64, Limits::none()).unwrap();
271        match envelope {
272            TransactionEnvelope::Tx(env) => {
273                assert_eq!(env.tx.fee, 150);
274                assert_eq!(env.tx.seq_num.0, 123);
275                match env.tx.memo {
276                    Memo::Id(id) => assert_eq!(id, 456),
277                    _ => panic!("Wrong memo"),
278                }
279            }
280            _ => panic!("Expected Tx (V1)"),
281        }
282    }
283
284    const TEST_CONTRACT: &str = "CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526";
285
286    #[test]
287    fn test_builder_invoke_contract_success() {
288        let b64 = TxBuilder::new()
289            .source_account(TEST_SOURCE)
290            .unwrap()
291            .sequence_number(5)
292            .fee(200)
293            .invoke_contract(
294                TEST_CONTRACT,
295                "transfer",
296                vec![
297                    stellar_xdr::ScVal::U32(100),
298                    stellar_xdr::ScVal::String(stellar_xdr::ScString(
299                        "hello".as_bytes().to_vec().try_into().unwrap(),
300                    )),
301                    stellar_xdr::ScVal::Bool(true),
302                ],
303            )
304            .unwrap()
305            .build_base64()
306            .unwrap();
307        let envelope = TransactionEnvelope::from_xdr_base64(b64, Limits::none()).unwrap();
308        match envelope {
309            TransactionEnvelope::Tx(env) => {
310                assert_eq!(env.tx.operations.len(), 1);
311                match env.tx.operations.first().unwrap().body {
312                    OperationBody::InvokeHostFunction(ref op) => match op.host_function {
313                        stellar_xdr::HostFunction::InvokeContract(ref args) => {
314                            assert_eq!(args.function_name.to_utf8_string_lossy(), "transfer");
315                            assert_eq!(args.args.len(), 3);
316                        }
317                        _ => panic!("Expected InvokeContract"),
318                    },
319                    _ => panic!("Expected InvokeHostFunction operation"),
320                }
321            }
322            _ => panic!("Expected Tx (V1)"),
323        }
324    }
325
326    #[test]
327    fn test_builder_invoke_contract_empty_args() {
328        let b64 = TxBuilder::new()
329            .source_account(TEST_SOURCE)
330            .unwrap()
331            .sequence_number(1)
332            .invoke_contract(TEST_CONTRACT, "hello", vec![])
333            .unwrap()
334            .build_base64()
335            .unwrap();
336        let envelope = TransactionEnvelope::from_xdr_base64(b64, Limits::none()).unwrap();
337        match envelope {
338            TransactionEnvelope::Tx(env) => match env.tx.operations.first().unwrap().body {
339                OperationBody::InvokeHostFunction(ref op) => match op.host_function {
340                    stellar_xdr::HostFunction::InvokeContract(ref args) => {
341                        assert_eq!(args.function_name.to_utf8_string_lossy(), "hello");
342                        assert_eq!(args.args.len(), 0);
343                    }
344                    _ => panic!("Expected InvokeContract"),
345                },
346                _ => panic!("Expected InvokeHostFunction"),
347            },
348            _ => panic!("Expected Tx"),
349        }
350    }
351
352    #[test]
353    fn test_builder_invoke_contract_invalid_contract_id() {
354        let res = TxBuilder::new()
355            .source_account(TEST_SOURCE)
356            .unwrap()
357            .sequence_number(1)
358            .invoke_contract("GNOTACONTRACT", "hello", vec![]);
359        assert!(matches!(res, Err(BuilderError::InvalidOperation(_))));
360    }
361}