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
24pub 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, 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 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 pub fn sequence_number(mut self, seq: i64) -> Self {
71 self.sequence_number = Some(seq);
72 self
73 }
74
75 pub fn fee(mut self, fee: u32) -> Self {
77 self.fee = fee;
78 self
79 }
80
81 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 pub fn memo_id(mut self, id: u64) -> Self {
93 self.memo = Memo::Id(id);
94 self
95 }
96
97 pub fn add_operation(mut self, op: Operation) -> Self {
99 self.operations.push(op);
100 self
101 }
102
103 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 pub fn set_ext(mut self, ext: TransactionExt) -> Self {
148 self.ext = ext;
149 self
150 }
151
152 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 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(), };
194
195 Ok(TransactionEnvelope::Tx(env_v1))
196 }
197
198 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 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}