use stellar_strkey::Strkey;
use stellar_xdr::{
ContractId, Error as XdrError, Hash, HostFunction, InvokeContractArgs, InvokeHostFunctionOp,
Limits, Memo, MuxedAccount, Operation, OperationBody, Preconditions, ScAddress, ScSymbol,
SequenceNumber, TimeBounds, Transaction, TransactionEnvelope, TransactionExt,
TransactionV1Envelope, Uint256, VecM, WriteXdr,
};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum BuilderError {
#[error("Invalid source account: {0}")]
InvalidSourceAccount(String),
#[error("Invalid memo: {0}")]
InvalidMemo(String),
#[error("XDR encoding error: {0}")]
XdrError(#[from] XdrError),
#[error("Missing operation")]
MissingOperation,
#[error("Invalid operation: {0}")]
InvalidOperation(String),
}
pub struct TxBuilder {
source_account: Option<MuxedAccount>,
sequence_number: Option<i64>,
fee: u32,
memo: Memo,
preconditions: Preconditions,
operations: Vec<Operation>,
ext: TransactionExt,
}
impl Default for TxBuilder {
fn default() -> Self {
Self {
source_account: None,
sequence_number: None,
fee: 100, memo: Memo::None,
preconditions: Preconditions::None,
operations: Vec::new(),
ext: TransactionExt::V0,
}
}
}
impl TxBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn source_account(mut self, address: &str) -> Result<Self, BuilderError> {
let key = Strkey::from_string(address)
.map_err(|e| BuilderError::InvalidSourceAccount(e.to_string()))?;
if let Strkey::PublicKeyEd25519(pk) = key {
self.source_account = Some(MuxedAccount::Ed25519(Uint256(pk.0)));
Ok(self)
} else {
Err(BuilderError::InvalidSourceAccount(
"Must be a G... public key".into(),
))
}
}
pub fn sequence_number(mut self, seq: i64) -> Self {
self.sequence_number = Some(seq);
self
}
pub fn fee(mut self, fee: u32) -> Self {
self.fee = fee;
self
}
pub fn memo_text(mut self, text: &str) -> Result<Self, BuilderError> {
let bytes = text.as_bytes();
let vec_m = bytes
.try_into()
.map_err(|_| BuilderError::InvalidMemo("Text too long".into()))?;
self.memo = Memo::Text(vec_m);
Ok(self)
}
pub fn memo_id(mut self, id: u64) -> Self {
self.memo = Memo::Id(id);
self
}
pub fn add_operation(mut self, op: Operation) -> Self {
self.operations.push(op);
self
}
pub fn invoke_contract(
mut self,
contract_id: &str,
function: &str,
args: Vec<stellar_xdr::ScVal>,
) -> Result<Self, BuilderError> {
let hash = decode_contract_id_direct(contract_id)
.map_err(|e| BuilderError::InvalidOperation(format!("contract ID: {e}")))?;
let sc_symbol: stellar_xdr::StringM<32> = function
.as_bytes()
.try_into()
.map_err(|_| BuilderError::InvalidOperation("function name too long".into()))?;
let function_name = ScSymbol(sc_symbol);
let args_vec: VecM<stellar_xdr::ScVal> = args
.try_into()
.map_err(|_| BuilderError::InvalidOperation("too many args".into()))?;
let op = Operation {
source_account: None,
body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
host_function: HostFunction::InvokeContract(InvokeContractArgs {
contract_address: ScAddress::Contract(ContractId(hash)),
function_name,
args: args_vec,
}),
auth: VecM::default(),
}),
};
self.operations.push(op);
Ok(self)
}
pub fn set_ext(mut self, ext: TransactionExt) -> Self {
self.ext = ext;
self
}
pub fn max_time(mut self, max_time: u64) -> Self {
self.preconditions = Preconditions::Time(TimeBounds {
min_time: stellar_xdr::TimePoint(0),
max_time: stellar_xdr::TimePoint(max_time),
});
self
}
pub fn build(self) -> Result<TransactionEnvelope, BuilderError> {
let source_account = self
.source_account
.ok_or_else(|| BuilderError::InvalidSourceAccount("Missing source account".into()))?;
let seq = self
.sequence_number
.ok_or_else(|| BuilderError::InvalidSourceAccount("Missing sequence number".into()))?;
if self.operations.is_empty() {
return Err(BuilderError::MissingOperation);
}
let ops_vec: VecM<Operation, 100> = self
.operations
.try_into()
.map_err(|_| BuilderError::InvalidOperation("Too many operations".into()))?;
let tx = Transaction {
source_account,
fee: self.fee,
seq_num: SequenceNumber(seq),
cond: self.preconditions,
memo: self.memo,
operations: ops_vec,
ext: self.ext,
};
let env_v1 = TransactionV1Envelope {
tx,
signatures: VecM::default(), };
Ok(TransactionEnvelope::Tx(env_v1))
}
pub fn build_base64(self) -> Result<String, BuilderError> {
let envelope = self.build()?;
let b64 = envelope.to_xdr_base64(Limits::none())?;
Ok(b64)
}
}
fn decode_contract_id_direct(contract_id: &str) -> Result<Hash, String> {
let key = Strkey::from_string(contract_id).map_err(|e| e.to_string())?;
match key {
Strkey::Contract(c) => Ok(Hash(c.0)),
_ => Err("Expected C... contract StrKey".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use stellar_xdr::{OperationBody, ReadXdr};
#[test]
fn test_builder_missing_source() {
let builder = TxBuilder::new().sequence_number(1);
assert!(matches!(
builder.build(),
Err(BuilderError::InvalidSourceAccount(_))
));
}
const TEST_SOURCE: &str = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
#[test]
fn test_builder_missing_operations() {
let builder = TxBuilder::new()
.source_account(TEST_SOURCE)
.unwrap()
.sequence_number(1);
assert!(matches!(
builder.build(),
Err(BuilderError::MissingOperation)
));
}
#[test]
fn test_builder_invalid_memo() {
let res = TxBuilder::new().memo_text(
"this text is way too long to fit into a stellar memo which has a 28 char limit",
);
assert!(matches!(res, Err(BuilderError::InvalidMemo(_))));
}
#[test]
fn test_valid_build_and_encode() {
let op = Operation {
source_account: None,
body: OperationBody::BumpSequence(stellar_xdr::BumpSequenceOp {
bump_to: SequenceNumber(10),
}),
};
let b64 = TxBuilder::new()
.source_account(TEST_SOURCE)
.unwrap()
.sequence_number(123)
.fee(150)
.memo_id(456)
.add_operation(op)
.build_base64()
.unwrap();
let envelope = TransactionEnvelope::from_xdr_base64(b64, Limits::none()).unwrap();
match envelope {
TransactionEnvelope::Tx(env) => {
assert_eq!(env.tx.fee, 150);
assert_eq!(env.tx.seq_num.0, 123);
match env.tx.memo {
Memo::Id(id) => assert_eq!(id, 456),
_ => panic!("Wrong memo"),
}
}
_ => panic!("Expected Tx (V1)"),
}
}
const TEST_CONTRACT: &str = "CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526";
#[test]
fn test_builder_invoke_contract_success() {
let b64 = TxBuilder::new()
.source_account(TEST_SOURCE)
.unwrap()
.sequence_number(5)
.fee(200)
.invoke_contract(
TEST_CONTRACT,
"transfer",
vec![
stellar_xdr::ScVal::U32(100),
stellar_xdr::ScVal::String(stellar_xdr::ScString(
"hello".as_bytes().to_vec().try_into().unwrap(),
)),
stellar_xdr::ScVal::Bool(true),
],
)
.unwrap()
.build_base64()
.unwrap();
let envelope = TransactionEnvelope::from_xdr_base64(b64, Limits::none()).unwrap();
match envelope {
TransactionEnvelope::Tx(env) => {
assert_eq!(env.tx.operations.len(), 1);
match env.tx.operations.first().unwrap().body {
OperationBody::InvokeHostFunction(ref op) => match op.host_function {
stellar_xdr::HostFunction::InvokeContract(ref args) => {
assert_eq!(args.function_name.to_utf8_string_lossy(), "transfer");
assert_eq!(args.args.len(), 3);
}
_ => panic!("Expected InvokeContract"),
},
_ => panic!("Expected InvokeHostFunction operation"),
}
}
_ => panic!("Expected Tx (V1)"),
}
}
#[test]
fn test_builder_invoke_contract_empty_args() {
let b64 = TxBuilder::new()
.source_account(TEST_SOURCE)
.unwrap()
.sequence_number(1)
.invoke_contract(TEST_CONTRACT, "hello", vec![])
.unwrap()
.build_base64()
.unwrap();
let envelope = TransactionEnvelope::from_xdr_base64(b64, Limits::none()).unwrap();
match envelope {
TransactionEnvelope::Tx(env) => match env.tx.operations.first().unwrap().body {
OperationBody::InvokeHostFunction(ref op) => match op.host_function {
stellar_xdr::HostFunction::InvokeContract(ref args) => {
assert_eq!(args.function_name.to_utf8_string_lossy(), "hello");
assert_eq!(args.args.len(), 0);
}
_ => panic!("Expected InvokeContract"),
},
_ => panic!("Expected InvokeHostFunction"),
},
_ => panic!("Expected Tx"),
}
}
#[test]
fn test_builder_invoke_contract_invalid_contract_id() {
let res = TxBuilder::new()
.source_account(TEST_SOURCE)
.unwrap()
.sequence_number(1)
.invoke_contract("GNOTACONTRACT", "hello", vec![]);
assert!(matches!(res, Err(BuilderError::InvalidOperation(_))));
}
}