Skip to main content

sof_tx/
builder.rs

1//! High-level transaction builder APIs.
2
3use solana_compute_budget_interface::ComputeBudgetInstruction;
4use solana_message::{Hash, Instruction, Message, VersionedMessage, v0};
5use solana_packet::PACKET_DATA_SIZE;
6use solana_pubkey::Pubkey;
7use solana_signer::{SignerError, signers::Signers};
8use solana_system_interface::instruction as system_instruction;
9use solana_transaction::sanitized::MAX_TX_ACCOUNT_LOCKS as SOLANA_MAX_TX_ACCOUNT_LOCKS;
10use solana_transaction::versioned::VersionedTransaction;
11use thiserror::Error;
12
13/// Default lamports tipped by [`TxBuilder::tip_developer`].
14pub const DEFAULT_DEVELOPER_TIP_LAMPORTS: u64 = 5_000;
15
16/// Default developer tip recipient used by [`TxBuilder::tip_developer`].
17pub const DEFAULT_DEVELOPER_TIP_RECIPIENT: Pubkey =
18    Pubkey::from_str_const("G3WHMVjx7Cb3MFhBAHe52zw8yhbHodWnas5gYLceaqze");
19
20/// Current maximum serialized transaction payload size accepted by Solana networking.
21pub const MAX_TRANSACTION_WIRE_BYTES: usize = PACKET_DATA_SIZE;
22
23/// Current maximum number of account locks allowed per transaction.
24pub const MAX_TRANSACTION_ACCOUNT_LOCKS: usize = SOLANA_MAX_TX_ACCOUNT_LOCKS;
25
26/// Transaction message version emitted by [`TxBuilder`].
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum TxMessageVersion {
29    /// Legacy message encoding.
30    Legacy,
31    /// Version 0 message encoding.
32    #[default]
33    V0,
34}
35
36/// Builder-layer errors.
37#[derive(Debug, Error)]
38pub enum BuilderError {
39    /// Signing failed with signer-level error.
40    #[error("failed to sign transaction: {source}")]
41    SignTransaction {
42        /// Underlying signer error.
43        source: SignerError,
44    },
45}
46
47/// Unsigned transaction wrapper.
48#[derive(Debug, Clone)]
49pub struct UnsignedTx {
50    /// Versioned message ready to sign.
51    message: VersionedMessage,
52}
53
54impl UnsignedTx {
55    /// Returns the message payload.
56    #[must_use]
57    pub const fn message(&self) -> &VersionedMessage {
58        &self.message
59    }
60
61    /// Signs the message with provided signers.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`BuilderError::SignTransaction`] when signer validation or signing fails.
66    pub fn sign<T>(self, signers: &T) -> Result<VersionedTransaction, BuilderError>
67    where
68        T: Signers + ?Sized,
69    {
70        VersionedTransaction::try_new(self.message, signers)
71            .map_err(|source| BuilderError::SignTransaction { source })
72    }
73}
74
75/// High-level builder for Solana versioned transaction messages.
76#[derive(Debug, Clone)]
77pub struct TxBuilder {
78    /// Fee payer and signer.
79    payer: Pubkey,
80    /// User-provided instructions.
81    instructions: Vec<Instruction>,
82    /// Optional compute unit limit.
83    compute_unit_limit: Option<u32>,
84    /// Optional priority fee (micro-lamports per compute unit).
85    priority_fee_micro_lamports: Option<u64>,
86    /// Optional developer tip lamports.
87    developer_tip_lamports: Option<u64>,
88    /// Tip recipient used when tip is enabled.
89    developer_tip_recipient: Pubkey,
90    /// Message version emitted by the builder.
91    message_version: TxMessageVersion,
92}
93
94impl TxBuilder {
95    /// Creates a transaction builder for a fee payer.
96    #[must_use]
97    pub const fn new(payer: Pubkey) -> Self {
98        Self {
99            payer,
100            instructions: Vec::new(),
101            compute_unit_limit: None,
102            priority_fee_micro_lamports: None,
103            developer_tip_lamports: None,
104            developer_tip_recipient: DEFAULT_DEVELOPER_TIP_RECIPIENT,
105            message_version: TxMessageVersion::V0,
106        }
107    }
108
109    /// Appends one instruction.
110    #[must_use]
111    pub fn add_instruction(mut self, instruction: Instruction) -> Self {
112        self.instructions.push(instruction);
113        self
114    }
115
116    /// Appends many instructions.
117    #[must_use]
118    pub fn add_instructions<I>(mut self, instructions: I) -> Self
119    where
120        I: IntoIterator<Item = Instruction>,
121    {
122        self.instructions.extend(instructions);
123        self
124    }
125
126    /// Sets compute unit limit.
127    #[must_use]
128    pub const fn with_compute_unit_limit(mut self, units: u32) -> Self {
129        self.compute_unit_limit = Some(units);
130        self
131    }
132
133    /// Removes any explicit compute unit limit instruction.
134    #[must_use]
135    pub const fn without_compute_unit_limit(mut self) -> Self {
136        self.compute_unit_limit = None;
137        self
138    }
139
140    /// Sets priority fee in micro-lamports.
141    #[must_use]
142    pub const fn with_priority_fee_micro_lamports(mut self, micro_lamports: u64) -> Self {
143        self.priority_fee_micro_lamports = Some(micro_lamports);
144        self
145    }
146
147    /// Removes any explicit priority-fee instruction.
148    #[must_use]
149    pub const fn without_priority_fee_micro_lamports(mut self) -> Self {
150        self.priority_fee_micro_lamports = None;
151        self
152    }
153
154    /// Enables default developer tip.
155    #[must_use]
156    pub const fn tip_developer(mut self) -> Self {
157        self.developer_tip_lamports = Some(DEFAULT_DEVELOPER_TIP_LAMPORTS);
158        self
159    }
160
161    /// Enables developer tip with explicit lamports.
162    #[must_use]
163    pub const fn tip_developer_lamports(mut self, lamports: u64) -> Self {
164        self.developer_tip_lamports = Some(lamports);
165        self
166    }
167
168    /// Sets a custom tip recipient and lamports.
169    #[must_use]
170    pub const fn tip_to(mut self, recipient: Pubkey, lamports: u64) -> Self {
171        self.developer_tip_recipient = recipient;
172        self.developer_tip_lamports = Some(lamports);
173        self
174    }
175
176    /// Sets the message version emitted by the builder.
177    #[must_use]
178    pub const fn with_message_version(mut self, version: TxMessageVersion) -> Self {
179        self.message_version = version;
180        self
181    }
182
183    /// Forces legacy message output.
184    #[must_use]
185    pub const fn with_legacy_message(self) -> Self {
186        self.with_message_version(TxMessageVersion::Legacy)
187    }
188
189    /// Forces version 0 message output.
190    #[must_use]
191    pub const fn with_v0_message(self) -> Self {
192        self.with_message_version(TxMessageVersion::V0)
193    }
194
195    /// Builds an unsigned transaction wrapper.
196    #[must_use]
197    pub fn build_unsigned(self, recent_blockhash: [u8; 32]) -> UnsignedTx {
198        UnsignedTx {
199            message: self.build_message(recent_blockhash),
200        }
201    }
202
203    /// Builds and signs a transaction in one step.
204    ///
205    /// # Errors
206    ///
207    /// Returns [`BuilderError::SignTransaction`] when signer validation or signing fails.
208    pub fn build_and_sign<T>(
209        self,
210        recent_blockhash: [u8; 32],
211        signers: &T,
212    ) -> Result<VersionedTransaction, BuilderError>
213    where
214        T: Signers + ?Sized,
215    {
216        self.build_unsigned(recent_blockhash).sign(signers)
217    }
218
219    /// Builds a message wrapped as a versioned message.
220    #[must_use]
221    pub fn build_message(self, recent_blockhash: [u8; 32]) -> VersionedMessage {
222        let mut instructions = Vec::new();
223        if let Some(units) = self.compute_unit_limit {
224            instructions.push(ComputeBudgetInstruction::set_compute_unit_limit(units));
225        }
226        if let Some(micro_lamports) = self.priority_fee_micro_lamports {
227            instructions.push(ComputeBudgetInstruction::set_compute_unit_price(
228                micro_lamports,
229            ));
230        }
231        instructions.extend(self.instructions);
232        if let Some(lamports) = self.developer_tip_lamports {
233            instructions.push(system_instruction::transfer(
234                &self.payer,
235                &self.developer_tip_recipient,
236                lamports,
237            ));
238        }
239        let blockhash = Hash::new_from_array(recent_blockhash);
240        let legacy_message =
241            Message::new_with_blockhash(&instructions, Some(&self.payer), &blockhash);
242        match self.message_version {
243            TxMessageVersion::Legacy => VersionedMessage::Legacy(legacy_message),
244            TxMessageVersion::V0 => VersionedMessage::V0(v0::Message {
245                header: legacy_message.header,
246                account_keys: legacy_message.account_keys,
247                recent_blockhash: legacy_message.recent_blockhash,
248                instructions: legacy_message.instructions,
249                address_table_lookups: Vec::new(),
250            }),
251        }
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use solana_keypair::Keypair;
258    use solana_signer::Signer;
259
260    use super::*;
261
262    #[test]
263    fn tip_developer_adds_system_transfer_instruction() {
264        let payer = Keypair::new();
265        let message = TxBuilder::new(payer.pubkey())
266            .tip_developer()
267            .build_message([1_u8; 32]);
268
269        let keys = message.static_account_keys();
270        let instructions = message.instructions();
271        assert_eq!(instructions.len(), 1);
272        assert!(matches!(message, VersionedMessage::V0(_)));
273
274        let first = instructions.first();
275        assert!(first.is_some());
276        if let Some(instruction) = first {
277            let program_idx = usize::from(instruction.program_id_index);
278            let program = keys.get(program_idx);
279            assert!(program.is_some());
280            if let Some(program) = program {
281                assert_eq!(*program, solana_system_interface::program::ID);
282            }
283        }
284    }
285
286    #[test]
287    fn compute_budget_instructions_are_prefixed() {
288        let payer = Keypair::new();
289        let recipient = Pubkey::new_unique();
290        let message = TxBuilder::new(payer.pubkey())
291            .with_compute_unit_limit(500_000)
292            .with_priority_fee_micro_lamports(10_000)
293            .add_instruction(system_instruction::transfer(&payer.pubkey(), &recipient, 1))
294            .build_message([2_u8; 32]);
295
296        let instructions = message.instructions();
297        assert_eq!(instructions.len(), 3);
298        assert!(matches!(message, VersionedMessage::V0(_)));
299        let first = instructions.first();
300        assert!(first.is_some());
301        if let Some(first) = first {
302            assert_eq!(first.data.first().copied(), Some(2_u8));
303        }
304        let second = instructions.get(1);
305        assert!(second.is_some());
306        if let Some(second) = second {
307            assert_eq!(second.data.first().copied(), Some(3_u8));
308        }
309    }
310
311    #[test]
312    fn build_and_sign_generates_signature() {
313        let payer = Keypair::new();
314        let recipient = Pubkey::new_unique();
315        let tx_result = TxBuilder::new(payer.pubkey())
316            .add_instruction(system_instruction::transfer(&payer.pubkey(), &recipient, 1))
317            .build_and_sign([3_u8; 32], &[&payer]);
318
319        assert!(tx_result.is_ok());
320        if let Ok(tx) = tx_result {
321            assert_eq!(tx.signatures.len(), 1);
322            let first = tx.signatures.first();
323            assert!(first.is_some());
324            if let Some(first) = first {
325                assert_ne!(*first, solana_signature::Signature::default());
326            }
327        }
328    }
329
330    #[test]
331    fn legacy_message_override_builds_legacy_message() {
332        let payer = Keypair::new();
333        let recipient = Pubkey::new_unique();
334        let message = TxBuilder::new(payer.pubkey())
335            .with_legacy_message()
336            .add_instruction(system_instruction::transfer(&payer.pubkey(), &recipient, 1))
337            .build_message([4_u8; 32]);
338
339        assert!(matches!(message, VersionedMessage::Legacy(_)));
340    }
341
342    #[test]
343    fn exported_limit_constants_match_runtime_constants() {
344        assert_eq!(MAX_TRANSACTION_WIRE_BYTES, PACKET_DATA_SIZE);
345        assert_eq!(MAX_TRANSACTION_ACCOUNT_LOCKS, SOLANA_MAX_TX_ACCOUNT_LOCKS);
346    }
347}