sof-tx 0.18.2

SOF transaction SDK for building and submitting Solana transactions
Documentation
//! High-level transaction builder APIs.

use solana_compute_budget_interface::ComputeBudgetInstruction;
use solana_message::{Hash, Instruction, Message, VersionedMessage, v0};
use solana_packet::PACKET_DATA_SIZE;
use solana_pubkey::Pubkey;
use solana_signer::{SignerError, signers::Signers};
use solana_system_interface::instruction as system_instruction;
use solana_transaction::sanitized::MAX_TX_ACCOUNT_LOCKS as SOLANA_MAX_TX_ACCOUNT_LOCKS;
use solana_transaction::versioned::VersionedTransaction;
use thiserror::Error;

/// Default lamports tipped by [`TxBuilder::tip_developer`].
pub const DEFAULT_DEVELOPER_TIP_LAMPORTS: u64 = 5_000;

/// Default developer tip recipient used by [`TxBuilder::tip_developer`].
pub const DEFAULT_DEVELOPER_TIP_RECIPIENT: Pubkey =
    Pubkey::from_str_const("G3WHMVjx7Cb3MFhBAHe52zw8yhbHodWnas5gYLceaqze");

/// Current maximum serialized transaction payload size accepted by Solana networking.
pub const MAX_TRANSACTION_WIRE_BYTES: usize = PACKET_DATA_SIZE;

/// Current maximum number of account locks allowed per transaction.
pub const MAX_TRANSACTION_ACCOUNT_LOCKS: usize = SOLANA_MAX_TX_ACCOUNT_LOCKS;

/// Transaction message version emitted by [`TxBuilder`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TxMessageVersion {
    /// Legacy message encoding.
    Legacy,
    /// Version 0 message encoding.
    #[default]
    V0,
}

/// Builder-layer errors.
#[derive(Debug, Error)]
pub enum BuilderError {
    /// Signing failed with signer-level error.
    #[error("failed to sign transaction: {source}")]
    SignTransaction {
        /// Underlying signer error.
        source: SignerError,
    },
}

/// Unsigned transaction wrapper.
#[derive(Debug, Clone)]
pub struct UnsignedTx {
    /// Versioned message ready to sign.
    message: VersionedMessage,
}

impl UnsignedTx {
    /// Returns the message payload.
    #[must_use]
    pub const fn message(&self) -> &VersionedMessage {
        &self.message
    }

    /// Signs the message with provided signers.
    ///
    /// # Errors
    ///
    /// Returns [`BuilderError::SignTransaction`] when signer validation or signing fails.
    pub fn sign<T>(self, signers: &T) -> Result<VersionedTransaction, BuilderError>
    where
        T: Signers + ?Sized,
    {
        VersionedTransaction::try_new(self.message, signers)
            .map_err(|source| BuilderError::SignTransaction { source })
    }
}

/// High-level builder for Solana versioned transaction messages.
#[derive(Debug, Clone)]
pub struct TxBuilder {
    /// Fee payer and signer.
    payer: Pubkey,
    /// User-provided instructions.
    instructions: Vec<Instruction>,
    /// Optional compute unit limit.
    compute_unit_limit: Option<u32>,
    /// Optional priority fee (micro-lamports per compute unit).
    priority_fee_micro_lamports: Option<u64>,
    /// Optional developer tip lamports.
    developer_tip_lamports: Option<u64>,
    /// Tip recipient used when tip is enabled.
    developer_tip_recipient: Pubkey,
    /// Message version emitted by the builder.
    message_version: TxMessageVersion,
}

impl TxBuilder {
    /// Creates a transaction builder for a fee payer.
    #[must_use]
    pub const fn new(payer: Pubkey) -> Self {
        Self {
            payer,
            instructions: Vec::new(),
            compute_unit_limit: None,
            priority_fee_micro_lamports: None,
            developer_tip_lamports: None,
            developer_tip_recipient: DEFAULT_DEVELOPER_TIP_RECIPIENT,
            message_version: TxMessageVersion::V0,
        }
    }

    /// Appends one instruction.
    #[must_use]
    pub fn add_instruction(mut self, instruction: Instruction) -> Self {
        self.instructions.push(instruction);
        self
    }

    /// Appends many instructions.
    #[must_use]
    pub fn add_instructions<I>(mut self, instructions: I) -> Self
    where
        I: IntoIterator<Item = Instruction>,
    {
        self.instructions.extend(instructions);
        self
    }

    /// Sets compute unit limit.
    #[must_use]
    pub const fn with_compute_unit_limit(mut self, units: u32) -> Self {
        self.compute_unit_limit = Some(units);
        self
    }

    /// Removes any explicit compute unit limit instruction.
    #[must_use]
    pub const fn without_compute_unit_limit(mut self) -> Self {
        self.compute_unit_limit = None;
        self
    }

    /// Sets priority fee in micro-lamports.
    #[must_use]
    pub const fn with_priority_fee_micro_lamports(mut self, micro_lamports: u64) -> Self {
        self.priority_fee_micro_lamports = Some(micro_lamports);
        self
    }

    /// Removes any explicit priority-fee instruction.
    #[must_use]
    pub const fn without_priority_fee_micro_lamports(mut self) -> Self {
        self.priority_fee_micro_lamports = None;
        self
    }

    /// Enables default developer tip.
    #[must_use]
    pub const fn tip_developer(mut self) -> Self {
        self.developer_tip_lamports = Some(DEFAULT_DEVELOPER_TIP_LAMPORTS);
        self
    }

    /// Enables developer tip with explicit lamports.
    #[must_use]
    pub const fn tip_developer_lamports(mut self, lamports: u64) -> Self {
        self.developer_tip_lamports = Some(lamports);
        self
    }

    /// Sets a custom tip recipient and lamports.
    #[must_use]
    pub const fn tip_to(mut self, recipient: Pubkey, lamports: u64) -> Self {
        self.developer_tip_recipient = recipient;
        self.developer_tip_lamports = Some(lamports);
        self
    }

    /// Sets the message version emitted by the builder.
    #[must_use]
    pub const fn with_message_version(mut self, version: TxMessageVersion) -> Self {
        self.message_version = version;
        self
    }

    /// Forces legacy message output.
    #[must_use]
    pub const fn with_legacy_message(self) -> Self {
        self.with_message_version(TxMessageVersion::Legacy)
    }

    /// Forces version 0 message output.
    #[must_use]
    pub const fn with_v0_message(self) -> Self {
        self.with_message_version(TxMessageVersion::V0)
    }

    /// Builds an unsigned transaction wrapper.
    #[must_use]
    pub fn build_unsigned(self, recent_blockhash: [u8; 32]) -> UnsignedTx {
        UnsignedTx {
            message: self.build_message(recent_blockhash),
        }
    }

    /// Builds and signs a transaction in one step.
    ///
    /// # Errors
    ///
    /// Returns [`BuilderError::SignTransaction`] when signer validation or signing fails.
    pub fn build_and_sign<T>(
        self,
        recent_blockhash: [u8; 32],
        signers: &T,
    ) -> Result<VersionedTransaction, BuilderError>
    where
        T: Signers + ?Sized,
    {
        self.build_unsigned(recent_blockhash).sign(signers)
    }

    /// Builds a message wrapped as a versioned message.
    #[must_use]
    pub fn build_message(self, recent_blockhash: [u8; 32]) -> VersionedMessage {
        let mut instructions = Vec::new();
        if let Some(units) = self.compute_unit_limit {
            instructions.push(ComputeBudgetInstruction::set_compute_unit_limit(units));
        }
        if let Some(micro_lamports) = self.priority_fee_micro_lamports {
            instructions.push(ComputeBudgetInstruction::set_compute_unit_price(
                micro_lamports,
            ));
        }
        instructions.extend(self.instructions);
        if let Some(lamports) = self.developer_tip_lamports {
            instructions.push(system_instruction::transfer(
                &self.payer,
                &self.developer_tip_recipient,
                lamports,
            ));
        }
        let blockhash = Hash::new_from_array(recent_blockhash);
        let legacy_message =
            Message::new_with_blockhash(&instructions, Some(&self.payer), &blockhash);
        match self.message_version {
            TxMessageVersion::Legacy => VersionedMessage::Legacy(legacy_message),
            TxMessageVersion::V0 => VersionedMessage::V0(v0::Message {
                header: legacy_message.header,
                account_keys: legacy_message.account_keys,
                recent_blockhash: legacy_message.recent_blockhash,
                instructions: legacy_message.instructions,
                address_table_lookups: Vec::new(),
            }),
        }
    }
}

#[cfg(test)]
mod tests {
    use solana_keypair::Keypair;
    use solana_signer::Signer;

    use super::*;

    #[test]
    fn tip_developer_adds_system_transfer_instruction() {
        let payer = Keypair::new();
        let message = TxBuilder::new(payer.pubkey())
            .tip_developer()
            .build_message([1_u8; 32]);

        let keys = message.static_account_keys();
        let instructions = message.instructions();
        assert_eq!(instructions.len(), 1);
        assert!(matches!(message, VersionedMessage::V0(_)));

        let first = instructions.first();
        assert!(first.is_some());
        if let Some(instruction) = first {
            let program_idx = usize::from(instruction.program_id_index);
            let program = keys.get(program_idx);
            assert!(program.is_some());
            if let Some(program) = program {
                assert_eq!(*program, solana_system_interface::program::ID);
            }
        }
    }

    #[test]
    fn compute_budget_instructions_are_prefixed() {
        let payer = Keypair::new();
        let recipient = Pubkey::new_unique();
        let message = TxBuilder::new(payer.pubkey())
            .with_compute_unit_limit(500_000)
            .with_priority_fee_micro_lamports(10_000)
            .add_instruction(system_instruction::transfer(&payer.pubkey(), &recipient, 1))
            .build_message([2_u8; 32]);

        let instructions = message.instructions();
        assert_eq!(instructions.len(), 3);
        assert!(matches!(message, VersionedMessage::V0(_)));
        let first = instructions.first();
        assert!(first.is_some());
        if let Some(first) = first {
            assert_eq!(first.data.first().copied(), Some(2_u8));
        }
        let second = instructions.get(1);
        assert!(second.is_some());
        if let Some(second) = second {
            assert_eq!(second.data.first().copied(), Some(3_u8));
        }
    }

    #[test]
    fn build_and_sign_generates_signature() {
        let payer = Keypair::new();
        let recipient = Pubkey::new_unique();
        let tx_result = TxBuilder::new(payer.pubkey())
            .add_instruction(system_instruction::transfer(&payer.pubkey(), &recipient, 1))
            .build_and_sign([3_u8; 32], &[&payer]);

        assert!(tx_result.is_ok());
        if let Ok(tx) = tx_result {
            assert_eq!(tx.signatures.len(), 1);
            let first = tx.signatures.first();
            assert!(first.is_some());
            if let Some(first) = first {
                assert_ne!(*first, solana_signature::Signature::default());
            }
        }
    }

    #[test]
    fn legacy_message_override_builds_legacy_message() {
        let payer = Keypair::new();
        let recipient = Pubkey::new_unique();
        let message = TxBuilder::new(payer.pubkey())
            .with_legacy_message()
            .add_instruction(system_instruction::transfer(&payer.pubkey(), &recipient, 1))
            .build_message([4_u8; 32]);

        assert!(matches!(message, VersionedMessage::Legacy(_)));
    }

    #[test]
    fn exported_limit_constants_match_runtime_constants() {
        assert_eq!(MAX_TRANSACTION_WIRE_BYTES, PACKET_DATA_SIZE);
        assert_eq!(MAX_TRANSACTION_ACCOUNT_LOCKS, SOLANA_MAX_TX_ACCOUNT_LOCKS);
    }
}