Skip to main content

bullet_rust_sdk/
transaction_builder.rs

1//! Fluent transaction builder for constructing and submitting transactions.
2//!
3//! All transaction construction goes through the builder pattern:
4//!
5//! ```ignore
6//! use bullet_rust_sdk::{Transaction, UnsignedTransaction, Client, Keypair};
7//!
8//! // Build and send with explicit signer
9//! let response = Transaction::builder()
10//!     .call_message(call_msg)
11//!     .max_fee(10_000_000)
12//!     .signer(&keypair)
13//!     .client(&client)
14//!     .build()?;
15//!
16//! // External signing
17//! let unsigned = UnsignedTransaction::builder()
18//!     .call_message(call_msg)
19//!     .max_fee(10_000_000)
20//!     .client(&client)
21//!     .build()?;
22//!
23//! let signable = unsigned.to_bytes()?;
24//! let signature: [u8; 64] = external_signer.sign(&signable);
25//! let pub_key: [u8; 32] = external_signer.public_key();
26//! let signed = Transaction::from_parts(unsigned, signature, pub_key);
27//!
28//! // Submit later
29//! client.send_transaction(&signed).await?;
30//! ```
31
32use base64::Engine;
33use base64::engine::general_purpose::STANDARD as BASE64;
34use bon::bon;
35use bullet_exchange_interface::transaction::{
36    Amount, Gas, PriorityFeeBips, RuntimeCall, Transaction as SignedTransaction, TxDetails,
37    UniquenessData, UnsignedTransaction as RawUnsignedTransaction, Version0,
38};
39use web_time::{SystemTime, UNIX_EPOCH};
40
41use crate::codegen::Error::ErrorResponse;
42use crate::generated::types::{SubmitTxRequest, SubmitTxResponse};
43use crate::types::CallMessage;
44use crate::{Client, Keypair, SDKError, SDKResult};
45
46// ── UnsignedTransaction ──────────────────────────────────────────────────────
47
48/// An unsigned transaction with the chain hash baked in.
49///
50/// Created by [`UnsignedTransaction::build`]. Contains everything
51/// needed to produce signable bytes without a client reference.
52pub struct UnsignedTransaction {
53    inner: RawUnsignedTransaction,
54    chain_hash: [u8; 32],
55}
56
57#[bon]
58impl UnsignedTransaction {
59    /// Serialize into the bytes that must be signed.
60    ///
61    /// Borsh-serializes the transaction and appends the chain hash (32 bytes)
62    /// as a domain separator.
63    pub fn to_bytes(&self) -> SDKResult<Vec<u8>> {
64        let mut data =
65            borsh::to_vec(&self.inner).map_err(|e| SDKError::SerializationError(e.to_string()))?;
66        data.extend_from_slice(&self.chain_hash);
67        Ok(data)
68    }
69
70    /// Build an unsigned transaction.
71    ///
72    /// The returned [`UnsignedTransaction`] contains the chain hash, so
73    /// [`to_bytes()`](UnsignedTransaction::to_bytes) produces signable bytes
74    /// without needing a client reference.
75    ///
76    /// # Example
77    ///
78    /// ```ignore
79    /// let unsigned = UnsignedTransaction::builder()
80    ///     .call_message(call_msg)
81    ///     .max_fee(10_000_000)
82    ///     .client(&client)
83    ///     .build()?;
84    ///
85    /// let signable = unsigned.to_bytes()?;
86    /// let signature: [u8; 64] = external_signer.sign(&signable);
87    /// let pub_key: [u8; 32] = external_signer.public_key();
88    /// let signed = Transaction::from_parts(unsigned, signature, pub_key);
89    /// ```
90    #[builder]
91    pub fn new(
92        call_message: CallMessage,
93        max_fee: u128,
94        priority_fee_bips: u64,
95        gas_limit: Option<Gas>,
96        client: &Client,
97    ) -> SDKResult<UnsignedTransaction> {
98        // Check whether the call-message was part of the schema validation
99        if let Some(user_actions) = client.user_actions()
100            && let CallMessage::User(ref call) = call_message
101            && !user_actions.contains(&call.into())
102        {
103            return Err(SDKError::UnsupportedCallMessage(call_message.msg_type()));
104        }
105
106        let runtime_call = RuntimeCall::Exchange(call_message);
107        let timestamp = SystemTime::now()
108            .duration_since(UNIX_EPOCH)
109            .map_err(|_| SDKError::SystemTimeError)?
110            .as_millis() as u64;
111        let uniqueness = UniquenessData::Generation(timestamp);
112        let details = TxDetails {
113            chain_id: client.chain_id(),
114            max_fee: Amount(max_fee),
115            gas_limit,
116            max_priority_fee_bips: PriorityFeeBips(priority_fee_bips),
117        };
118
119        Ok(UnsignedTransaction {
120            inner: RawUnsignedTransaction { runtime_call, uniqueness, details },
121            chain_hash: client.chain_hash(),
122        })
123    }
124}
125
126// ── Transaction ──────────────────────────────────────────────────────────────
127
128/// Transaction construction and serialization.
129///
130/// Use `Transaction::builder()` for signed transactions, or
131/// `UnsignedTransaction::builder()` for external signing.
132pub struct Transaction;
133
134#[bon]
135impl Transaction {
136    /// Build a signed transaction.
137    ///
138    /// Internally builds an unsigned transaction, serializes it,
139    /// signs with the provided keypair, and assembles the result.
140    ///
141    /// # Example
142    ///
143    /// ```ignore
144    /// let signed = Transaction::builder()
145    ///     .call_message(call_msg)
146    ///     .max_fee(10_000_000)
147    ///     .signer(&keypair)
148    ///     .client(&client)
149    ///     .build()?;
150    ///
151    /// client.send_transaction(&signed).await?;
152    /// ```
153    #[builder]
154    pub fn new(
155        call_message: CallMessage,
156        max_fee: Option<u128>,
157        priority_fee_bips: Option<u64>,
158        gas_limit: Option<Gas>,
159        signer: Option<&Keypair>,
160        client: &Client,
161    ) -> SDKResult<SignedTransaction> {
162        let signer = signer.or_else(|| client.keypair()).ok_or(SDKError::MissingKeypair)?;
163
164        let max_fee = max_fee.unwrap_or_else(|| client.max_fee().0);
165        let priority_fee_bips =
166            priority_fee_bips.unwrap_or_else(|| client.max_priority_fee_bips().0);
167        let gas_limit = gas_limit.or_else(|| client.gas_limit());
168
169        let unsigned = UnsignedTransaction::builder()
170            .call_message(call_message)
171            .max_fee(max_fee)
172            .priority_fee_bips(priority_fee_bips)
173            .maybe_gas_limit(gas_limit)
174            .client(client)
175            .build()?;
176
177        let data = unsigned.to_bytes()?;
178        let sig_bytes: [u8; 64] = signer
179            .sign(&data)
180            .try_into()
181            .map_err(|v: Vec<u8>| SDKError::InvalidSignatureLength(v.len()))?;
182        let pub_key: [u8; 32] = signer
183            .public_key()
184            .try_into()
185            .map_err(|v: Vec<u8>| SDKError::InvalidPublicKeyLength(v.len()))?;
186
187        Ok(Self::from_parts(unsigned, sig_bytes, pub_key))
188    }
189
190    /// Assemble a signed transaction from an unsigned transaction, a 64-byte
191    /// Ed25519 signature, and a 32-byte public key.
192    ///
193    /// Use after signing the bytes from [`UnsignedTransaction::to_bytes`].
194    pub fn from_parts(
195        tx: UnsignedTransaction,
196        signature: [u8; 64],
197        pub_key: [u8; 32],
198    ) -> SignedTransaction {
199        let RawUnsignedTransaction { runtime_call, uniqueness, details } = tx.inner;
200        SignedTransaction::V0(Version0 { runtime_call, uniqueness, details, pub_key, signature })
201    }
202
203    /// Borsh-serialize a signed transaction to bytes.
204    ///
205    /// Useful for byte-level comparison of two signed transactions.
206    pub fn to_bytes(signed: &SignedTransaction) -> SDKResult<Vec<u8>> {
207        borsh::to_vec(signed).map_err(|e| SDKError::SerializationError(e.to_string()))
208    }
209
210    /// Borsh-serialize and base64-encode a signed transaction.
211    pub fn to_base64(signed: &SignedTransaction) -> SDKResult<String> {
212        let bytes = Self::to_bytes(signed)?;
213        Ok(BASE64.encode(&bytes))
214    }
215}
216
217// ── Client methods ───────────────────────────────────────────────────────────
218impl Client {
219    /// Send a signed transaction to the network.
220    ///
221    /// Returns the response from the sequencer.
222    pub async fn send_transaction(
223        &self,
224        signed: &SignedTransaction,
225    ) -> SDKResult<SubmitTxResponse> {
226        let body = Transaction::to_base64(signed)?;
227        let response = self.client().submit_tx(&SubmitTxRequest { body }).await;
228        match response {
229            Err(ErrorResponse(response)) if response.status() == 401 => {
230                let inner = response.into_inner();
231                if inner.message.contains("Invalid signature") {
232                    self.update_schema().await?;
233                    // indicate that a the transaction must be re-signed and can not be simply
234                    // retried
235                    return Err(SDKError::TransactionOutdated);
236                }
237                Err(SDKError::ApiError(inner))
238            }
239            Ok(r) => Ok(r.into_inner()),
240            Err(e) => Err(e.into()),
241        }
242    }
243}
244
245// ── Tests ────────────────────────────────────────────────────────────────────
246
247#[cfg(test)]
248mod tests {
249    use bullet_exchange_interface::message::PublicAction;
250    use bullet_exchange_interface::transaction::{
251        Amount, PriorityFeeBips, RuntimeCall, TxDetails, UniquenessData,
252    };
253
254    use super::*;
255
256    fn test_unsigned_tx() -> UnsignedTransaction {
257        let inner = RawUnsignedTransaction {
258            runtime_call: RuntimeCall::Exchange(CallMessage::Public(PublicAction::ApplyFunding {
259                addresses: vec![],
260            })),
261            uniqueness: UniquenessData::Generation(12345),
262            details: TxDetails {
263                chain_id: 1,
264                max_fee: Amount(10_000_000),
265                gas_limit: None,
266                max_priority_fee_bips: PriorityFeeBips(0),
267            },
268        };
269        UnsignedTransaction { inner, chain_hash: [42u8; 32] }
270    }
271
272    #[test]
273    fn to_bytes_is_borsh_plus_chain_hash() {
274        let unsigned = test_unsigned_tx();
275        let bytes = unsigned.to_bytes().unwrap();
276
277        let mut expected = borsh::to_vec(&unsigned.inner).unwrap();
278        expected.extend_from_slice(&unsigned.chain_hash);
279        assert_eq!(bytes, expected);
280    }
281
282    #[test]
283    fn from_parts_matches_direct_construction() {
284        let keypair = Keypair::generate();
285        let unsigned = test_unsigned_tx();
286
287        let signable = unsigned.to_bytes().unwrap();
288        let sig: [u8; 64] = keypair.sign(&signable).try_into().unwrap();
289        let pk: [u8; 32] = keypair.public_key().try_into().unwrap();
290
291        // Reconstruct for direct comparison (from_parts consumes unsigned)
292        let chain_hash = unsigned.chain_hash;
293        let inner_clone = RawUnsignedTransaction {
294            runtime_call: unsigned.inner.runtime_call.clone(),
295            uniqueness: unsigned.inner.uniqueness.clone(),
296            details: unsigned.inner.details.clone(),
297        };
298        let assembled = Transaction::from_parts(unsigned, sig, pk);
299
300        // Direct Version0 construction
301        let mut data = borsh::to_vec(&inner_clone).unwrap();
302        data.extend_from_slice(&chain_hash);
303        let sig2: [u8; 64] = keypair.sign(&data).try_into().unwrap();
304        let direct = SignedTransaction::V0(Version0 {
305            runtime_call: inner_clone.runtime_call,
306            uniqueness: inner_clone.uniqueness,
307            details: inner_clone.details,
308            pub_key: pk,
309            signature: sig2,
310        });
311
312        assert_eq!(assembled, direct);
313        assert_eq!(
314            Transaction::to_bytes(&assembled).unwrap(),
315            Transaction::to_bytes(&direct).unwrap(),
316        );
317    }
318
319    #[test]
320    fn signed_to_bytes_roundtrips() {
321        let keypair = Keypair::generate();
322        let unsigned = test_unsigned_tx();
323
324        let signable = unsigned.to_bytes().unwrap();
325        let sig: [u8; 64] = keypair.sign(&signable).try_into().unwrap();
326        let pk: [u8; 32] = keypair.public_key().try_into().unwrap();
327        let signed = Transaction::from_parts(unsigned, sig, pk);
328
329        let bytes = Transaction::to_bytes(&signed).unwrap();
330        assert!(!bytes.is_empty());
331
332        let deserialized: SignedTransaction =
333            borsh::from_slice(&bytes).expect("should deserialize");
334        assert_eq!(bytes, Transaction::to_bytes(&deserialized).unwrap());
335    }
336
337    #[test]
338    fn to_base64_is_nonempty() {
339        let keypair = Keypair::generate();
340        let unsigned = test_unsigned_tx();
341
342        let signable = unsigned.to_bytes().unwrap();
343        let sig: [u8; 64] = keypair.sign(&signable).try_into().unwrap();
344        let pk: [u8; 32] = keypair.public_key().try_into().unwrap();
345        let signed = Transaction::from_parts(unsigned, sig, pk);
346
347        assert!(!Transaction::to_base64(&signed).unwrap().is_empty());
348    }
349
350    #[cfg(feature = "integration")]
351    mod integration {
352        use bullet_exchange_interface::message::PublicAction;
353
354        use super::*;
355        use crate::Network;
356
357        #[tokio::test]
358        async fn test_builder_build() {
359            let network = std::env::var("BULLET_API_ENDPOINT")
360                .map(|e| Network::from(e.as_str()))
361                .unwrap_or(Network::Mainnet);
362
363            let client =
364                Client::builder().network(network).build().await.expect("could not connect");
365            let keypair = Keypair::generate();
366
367            let call_msg = CallMessage::Public(PublicAction::ApplyFunding { addresses: vec![] });
368
369            let signed = Transaction::builder()
370                .call_message(call_msg)
371                .max_fee(10_000_000)
372                .signer(&keypair)
373                .client(&client)
374                .build()
375                .expect("Failed to build transaction");
376
377            assert!(!Transaction::to_base64(&signed).unwrap().is_empty());
378        }
379    }
380}