r402-svm 0.13.0

Solana chain support for the x402 payment protocol.
Documentation
//! Type definitions for the Solana "exact" payment scheme.
//!
//! This module defines shared wire format types for SPL Token based payments
//! on Solana. Wire format type aliases live in the [`v2`] sub-module.

use std::sync::LazyLock;

#[cfg(any(feature = "client", feature = "facilitator"))]
use r402::proto::Base64Bytes;
pub use r402::scheme::ExactScheme;
use serde::{Deserialize, Serialize};
#[cfg(feature = "facilitator")]
use solana_commitment_config::CommitmentConfig;
#[cfg(any(feature = "client", feature = "facilitator"))]
use solana_message::compiled_instruction::CompiledInstruction;
use solana_pubkey::{Pubkey, pubkey};
#[cfg(any(feature = "client", feature = "facilitator"))]
use solana_signature::Signature;
#[cfg(any(feature = "client", feature = "facilitator"))]
use solana_signer::Signer;
#[cfg(any(feature = "client", feature = "facilitator"))]
use solana_transaction::versioned::VersionedTransaction;

use crate::chain::Address;
#[cfg(feature = "facilitator")]
use crate::chain::{SolanaChainProviderError, SolanaChainProviderLike};
#[cfg(any(feature = "client", feature = "facilitator"))]
use crate::exact::{SolanaExactError, TransactionSignError, TransactionToB64Error};

/// Phantom Lighthouse program ID - security program injected by Phantom wallet on mainnet
/// See: <https://github.com/coinbase/x402/issues/828>
#[allow(clippy::expect_used, reason = "hardcoded constant is infallible")]
pub static PHANTOM_LIGHTHOUSE_PROGRAM: LazyLock<Pubkey> = LazyLock::new(|| {
    "L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95"
        .parse()
        .expect("Invalid Lighthouse program ID")
});

/// Solana exact payment payload containing a serialized transaction.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExactSolanaPayload {
    /// Base64-encoded serialized Solana transaction.
    pub transaction: String,
}

/// Extra fields for Solana payment kind support info.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SupportedPaymentKindExtra {
    /// The fee payer address for this payment kind.
    pub fee_payer: Address,
}

/// Associated Token Account program public key.
pub const ATA_PROGRAM_PUBKEY: Pubkey = pubkey!("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL");

/// Parsed instruction with its index and resolved account keys.
#[derive(Debug)]
#[cfg(any(feature = "client", feature = "facilitator"))]
pub struct InstructionInt {
    index: usize,
    instruction: CompiledInstruction,
    account_keys: Vec<Pubkey>,
}

/// Wrapper around a versioned Solana transaction with helper methods.
#[derive(Debug)]
#[cfg(any(feature = "client", feature = "facilitator"))]
pub struct TransactionInt {
    inner: VersionedTransaction,
}

#[cfg(any(feature = "client", feature = "facilitator"))]
impl TransactionInt {
    /// Creates a new transaction wrapper.
    #[must_use]
    pub const fn new(transaction: VersionedTransaction) -> Self {
        Self { inner: transaction }
    }

    /// Returns the inner transaction.
    #[must_use]
    pub const fn inner(&self) -> &VersionedTransaction {
        &self.inner
    }

    /// Returns the instruction at the given index.
    ///
    /// # Errors
    ///
    /// Returns [`SolanaExactError`] if the index is out of bounds.
    pub fn instruction(&self, index: usize) -> Result<InstructionInt, SolanaExactError> {
        let instruction = self
            .inner
            .message
            .instructions()
            .get(index)
            .cloned()
            .ok_or(SolanaExactError::NoInstructionAtIndex(index))?;
        let account_keys = self.inner.message.static_account_keys().to_vec();

        Ok(InstructionInt {
            index,
            instruction,
            account_keys,
        })
    }

    /// Checks if the transaction is fully signed.
    #[must_use]
    pub fn is_fully_signed(&self) -> bool {
        let num_required = self.inner.message.header().num_required_signatures;
        if self.inner.signatures.len() < num_required as usize {
            return false;
        }
        let default = Signature::default();
        for signature in &self.inner.signatures {
            if default.eq(signature) {
                return false;
            }
        }
        true
    }

    /// Signs the transaction using the chain provider.
    ///
    /// # Errors
    ///
    /// Returns [`SolanaChainProviderError`] if signing fails.
    #[cfg(feature = "facilitator")]
    pub fn sign<P: SolanaChainProviderLike>(
        self,
        provider: &P,
    ) -> Result<Self, SolanaChainProviderError> {
        let tx = provider.sign(self.inner)?;
        Ok(Self { inner: tx })
    }

    /// Signs the transaction with any Signer.
    ///
    /// # Errors
    ///
    /// Returns [`TransactionSignError`] if the signer is not in the required signers list.
    pub fn sign_with_keypair<S: Signer>(self, signer: &S) -> Result<Self, TransactionSignError> {
        let mut tx = self.inner;
        let msg_bytes = tx.message.serialize();
        let signature = signer
            .try_sign_message(msg_bytes.as_slice())
            .map_err(|e| TransactionSignError(format!("{e}")))?;

        let num_required = tx.message.header().num_required_signatures as usize;
        let static_keys = tx.message.static_account_keys();

        #[allow(
            clippy::indexing_slicing,
            reason = "num_required <= static_keys.len() by Solana message invariant"
        )]
        let pos = static_keys[..num_required]
            .iter()
            .position(|k| *k == signer.pubkey())
            .ok_or_else(|| {
                TransactionSignError("Signer not found in required signers".to_owned())
            })?;

        if tx.signatures.len() < num_required {
            tx.signatures.resize(num_required, Signature::default());
        }
        #[allow(
            clippy::indexing_slicing,
            reason = "pos < num_required, resize ensures len >= num_required"
        )]
        {
            tx.signatures[pos] = signature;
        }
        Ok(Self { inner: tx })
    }

    /// Sends the transaction and waits for confirmation.
    ///
    /// # Errors
    ///
    /// Returns [`SolanaChainProviderError`] if sending or confirmation fails.
    #[cfg(feature = "facilitator")]
    #[allow(
        clippy::needless_pass_by_value,
        reason = "CommitmentConfig is a small Copy type"
    )]
    pub async fn send_and_confirm<P: SolanaChainProviderLike>(
        &self,
        provider: &P,
        commitment_config: CommitmentConfig,
    ) -> Result<Signature, SolanaChainProviderError> {
        provider
            .send_and_confirm(&self.inner, commitment_config)
            .await
    }

    /// Encodes the transaction to base64.
    ///
    /// # Errors
    ///
    /// Returns [`TransactionToB64Error`] if serialization or encoding fails.
    pub fn as_base64(&self) -> Result<String, TransactionToB64Error> {
        let bytes =
            bincode::serialize(&self.inner).map_err(|e| TransactionToB64Error(format!("{e}")))?;
        let base64_bytes = Base64Bytes::encode(bytes);
        let string =
            String::from_utf8(base64_bytes.0).map_err(|e| TransactionToB64Error(format!("{e}")))?;
        Ok(string)
    }
}

#[cfg(any(feature = "client", feature = "facilitator"))]
impl InstructionInt {
    /// Checks if the instruction has data.
    #[must_use]
    pub const fn has_data(&self) -> bool {
        !self.instruction.data.is_empty()
    }

    /// Checks if the instruction has accounts.
    #[must_use]
    pub const fn has_accounts(&self) -> bool {
        !self.instruction.accounts.is_empty()
    }

    /// Returns the instruction data as a slice.
    #[must_use]
    pub const fn data_slice(&self) -> &[u8] {
        self.instruction.data.as_slice()
    }

    /// Asserts that the instruction is not empty.
    ///
    /// # Errors
    ///
    /// Returns [`SolanaExactError`] if the instruction is empty.
    pub const fn assert_not_empty(&self) -> Result<(), SolanaExactError> {
        if !self.has_data() || !self.has_accounts() {
            return Err(SolanaExactError::EmptyInstructionAtIndex(self.index));
        }
        Ok(())
    }

    /// Returns the program ID of the instruction.
    #[must_use]
    pub fn program_id(&self) -> Pubkey {
        *self.instruction.program_id(self.account_keys.as_slice())
    }

    /// Returns the account public key at the given index.
    ///
    /// # Errors
    ///
    /// Returns [`SolanaExactError`] if the index is out of bounds.
    pub fn account(&self, index: u8) -> Result<Pubkey, SolanaExactError> {
        let account_index = self
            .instruction
            .accounts
            .get(index as usize)
            .copied()
            .ok_or(SolanaExactError::NoAccountAtIndex(index))?;
        let pubkey = self
            .account_keys
            .get(account_index as usize)
            .copied()
            .ok_or(SolanaExactError::NoAccountAtIndex(index))?;
        Ok(pubkey)
    }
}

/// Wire format type aliases for Solana exact scheme.
///
/// Uses CAIP-2 chain IDs (e.g., `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`)
/// for chain identification and embeds requirements directly in the payload.
pub mod v2 {
    use r402::proto::U64String;
    use r402::proto::v2 as proto_v2;

    use super::{ExactScheme, ExactSolanaPayload, SupportedPaymentKindExtra};
    use crate::chain::Address;

    /// Type alias for verify requests using the exact Solana payment scheme.
    pub type VerifyRequest = proto_v2::VerifyRequest<PaymentPayload, PaymentRequirements>;

    /// Type alias for settle requests (same structure as verify requests).
    pub type SettleRequest = VerifyRequest;

    /// Type alias for payment payloads with embedded requirements and Solana-specific data.
    pub type PaymentPayload = proto_v2::PaymentPayload<PaymentRequirements, ExactSolanaPayload>;

    /// Type alias for payment requirements with Solana-specific types.
    pub type PaymentRequirements =
        proto_v2::PaymentRequirements<ExactScheme, U64String, Address, SupportedPaymentKindExtra>;
}