r402-svm 0.14.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;

pub use r402_core::scheme::ExactScheme;
#[cfg(any(feature = "client", feature = "facilitator"))]
use r402_core::wire::Base64Bytes;
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")
});

/// Solflare Lighthouse program ID — security program injected by Solflare wallet.
#[allow(clippy::expect_used, reason = "hardcoded constant is infallible")]
pub static SOLFLARE_LIGHTHOUSE_PROGRAM: LazyLock<Pubkey> = LazyLock::new(|| {
    "L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95"
        .parse()
        .expect("Invalid Solflare Lighthouse program ID")
});

/// SPL Memo program v2 (official).
///
/// Required by x402 v2 §SVM exact scheme when `SupportedPaymentKindExtra::memo`
/// is declared; the client transaction MUST contain exactly one memo
/// instruction whose data equals the declared memo.
pub const SPL_MEMO_PROGRAM: Pubkey = pubkey!("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr");

/// 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.
///
/// Per x402 v2 spec §SVM exact scheme the seller MAY declare:
///
/// - `fee_payer`: the facilitator address that will submit the transaction
/// - `memo`: arbitrary UTF-8 (≤ 256 bytes) that the buyer MUST include via
///   a single SPL Memo instruction — facilitators enforce this on verify.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SupportedPaymentKindExtra {
    /// The fee payer address for this payment kind.
    pub fee_payer: Address,
    /// Optional memo required in the transaction.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub memo: Option<compact_str::CompactString>,
}

/// 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_core::wire as proto_v2;
    use r402_core::wire::U64String;

    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::TypedVerifyRequest<2, 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>;
}