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    /// Sets priority fee in micro-lamports.
134    #[must_use]
135    pub const fn with_priority_fee_micro_lamports(mut self, micro_lamports: u64) -> Self {
136        self.priority_fee_micro_lamports = Some(micro_lamports);
137        self
138    }
139
140    /// Enables default developer tip.
141    #[must_use]
142    pub const fn tip_developer(mut self) -> Self {
143        self.developer_tip_lamports = Some(DEFAULT_DEVELOPER_TIP_LAMPORTS);
144        self
145    }
146
147    /// Enables developer tip with explicit lamports.
148    #[must_use]
149    pub const fn tip_developer_lamports(mut self, lamports: u64) -> Self {
150        self.developer_tip_lamports = Some(lamports);
151        self
152    }
153
154    /// Sets a custom tip recipient and lamports.
155    #[must_use]
156    pub const fn tip_to(mut self, recipient: Pubkey, lamports: u64) -> Self {
157        self.developer_tip_recipient = recipient;
158        self.developer_tip_lamports = Some(lamports);
159        self
160    }
161
162    /// Sets the message version emitted by the builder.
163    #[must_use]
164    pub const fn with_message_version(mut self, version: TxMessageVersion) -> Self {
165        self.message_version = version;
166        self
167    }
168
169    /// Forces legacy message output.
170    #[must_use]
171    pub const fn with_legacy_message(self) -> Self {
172        self.with_message_version(TxMessageVersion::Legacy)
173    }
174
175    /// Forces version 0 message output.
176    #[must_use]
177    pub const fn with_v0_message(self) -> Self {
178        self.with_message_version(TxMessageVersion::V0)
179    }
180
181    /// Builds an unsigned transaction wrapper.
182    #[must_use]
183    pub fn build_unsigned(self, recent_blockhash: [u8; 32]) -> UnsignedTx {
184        UnsignedTx {
185            message: self.build_message(recent_blockhash),
186        }
187    }
188
189    /// Builds and signs a transaction in one step.
190    ///
191    /// # Errors
192    ///
193    /// Returns [`BuilderError::SignTransaction`] when signer validation or signing fails.
194    pub fn build_and_sign<T>(
195        self,
196        recent_blockhash: [u8; 32],
197        signers: &T,
198    ) -> Result<VersionedTransaction, BuilderError>
199    where
200        T: Signers + ?Sized,
201    {
202        self.build_unsigned(recent_blockhash).sign(signers)
203    }
204
205    /// Builds a message wrapped as a versioned message.
206    #[must_use]
207    pub fn build_message(self, recent_blockhash: [u8; 32]) -> VersionedMessage {
208        let mut instructions = Vec::new();
209        if let Some(units) = self.compute_unit_limit {
210            instructions.push(ComputeBudgetInstruction::set_compute_unit_limit(units));
211        }
212        if let Some(micro_lamports) = self.priority_fee_micro_lamports {
213            instructions.push(ComputeBudgetInstruction::set_compute_unit_price(
214                micro_lamports,
215            ));
216        }
217        instructions.extend(self.instructions);
218        if let Some(lamports) = self.developer_tip_lamports {
219            instructions.push(system_instruction::transfer(
220                &self.payer,
221                &self.developer_tip_recipient,
222                lamports,
223            ));
224        }
225        let blockhash = Hash::new_from_array(recent_blockhash);
226        let legacy_message =
227            Message::new_with_blockhash(&instructions, Some(&self.payer), &blockhash);
228        match self.message_version {
229            TxMessageVersion::Legacy => VersionedMessage::Legacy(legacy_message),
230            TxMessageVersion::V0 => VersionedMessage::V0(v0::Message {
231                header: legacy_message.header,
232                account_keys: legacy_message.account_keys,
233                recent_blockhash: legacy_message.recent_blockhash,
234                instructions: legacy_message.instructions,
235                address_table_lookups: Vec::new(),
236            }),
237        }
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use solana_keypair::Keypair;
244    use solana_signer::Signer;
245
246    use super::*;
247
248    #[test]
249    fn tip_developer_adds_system_transfer_instruction() {
250        let payer = Keypair::new();
251        let message = TxBuilder::new(payer.pubkey())
252            .tip_developer()
253            .build_message([1_u8; 32]);
254
255        let keys = message.static_account_keys();
256        let instructions = message.instructions();
257        assert_eq!(instructions.len(), 1);
258        assert!(matches!(message, VersionedMessage::V0(_)));
259
260        let first = instructions.first();
261        assert!(first.is_some());
262        if let Some(instruction) = first {
263            let program_idx = usize::from(instruction.program_id_index);
264            let program = keys.get(program_idx);
265            assert!(program.is_some());
266            if let Some(program) = program {
267                assert_eq!(*program, solana_system_interface::program::ID);
268            }
269        }
270    }
271
272    #[test]
273    fn compute_budget_instructions_are_prefixed() {
274        let payer = Keypair::new();
275        let recipient = Pubkey::new_unique();
276        let message = TxBuilder::new(payer.pubkey())
277            .with_compute_unit_limit(500_000)
278            .with_priority_fee_micro_lamports(10_000)
279            .add_instruction(system_instruction::transfer(&payer.pubkey(), &recipient, 1))
280            .build_message([2_u8; 32]);
281
282        let instructions = message.instructions();
283        assert_eq!(instructions.len(), 3);
284        assert!(matches!(message, VersionedMessage::V0(_)));
285        let first = instructions.first();
286        assert!(first.is_some());
287        if let Some(first) = first {
288            assert_eq!(first.data.first().copied(), Some(2_u8));
289        }
290        let second = instructions.get(1);
291        assert!(second.is_some());
292        if let Some(second) = second {
293            assert_eq!(second.data.first().copied(), Some(3_u8));
294        }
295    }
296
297    #[test]
298    fn build_and_sign_generates_signature() {
299        let payer = Keypair::new();
300        let recipient = Pubkey::new_unique();
301        let tx_result = TxBuilder::new(payer.pubkey())
302            .add_instruction(system_instruction::transfer(&payer.pubkey(), &recipient, 1))
303            .build_and_sign([3_u8; 32], &[&payer]);
304
305        assert!(tx_result.is_ok());
306        if let Ok(tx) = tx_result {
307            assert_eq!(tx.signatures.len(), 1);
308            let first = tx.signatures.first();
309            assert!(first.is_some());
310            if let Some(first) = first {
311                assert_ne!(*first, solana_signature::Signature::default());
312            }
313        }
314    }
315
316    #[test]
317    fn legacy_message_override_builds_legacy_message() {
318        let payer = Keypair::new();
319        let recipient = Pubkey::new_unique();
320        let message = TxBuilder::new(payer.pubkey())
321            .with_legacy_message()
322            .add_instruction(system_instruction::transfer(&payer.pubkey(), &recipient, 1))
323            .build_message([4_u8; 32]);
324
325        assert!(matches!(message, VersionedMessage::Legacy(_)));
326    }
327
328    #[test]
329    fn exported_limit_constants_match_runtime_constants() {
330        assert_eq!(MAX_TRANSACTION_WIRE_BYTES, PACKET_DATA_SIZE);
331        assert_eq!(MAX_TRANSACTION_ACCOUNT_LOCKS, SOLANA_MAX_TX_ACCOUNT_LOCKS);
332    }
333}