wp-solana-pool-traits 0.1.1

Traits and utilities for Solana liquidity pool operations: PoolViewer, PoolInfuser, PositionViewer
Documentation
//! The [`Plannable`] trait contract and the [`PlannedTransaction`] data struct.
//!
//! This is the spine of the plan/fetch/orchestrate layering: every
//! protocol operation that can be executed on-chain produces a
//! [`PlannedTransaction`] from a snapshot and parameters, without touching
//! any RPC. `Plannable` is implemented on hand-written handle types in
//! each protocol SDK (e.g. `RaydiumAddLiquidity`), never on types from
//! any `src/generated/` directory.

use solana_sdk::{instruction::Instruction, signature::Keypair};
use wp_solana_tx::TransactionBuilder;

/// Output of a pure planning function: a bundle of instructions plus any
/// additional signers the instructions need (for example, a fresh position
/// NFT keypair for an "open position" operation).
///
/// `PlannedTransaction` is deliberately I/O-free. Converting it to a signed
/// transaction, fetching a blockhash, or sending it to the network all live
/// in `wp-solana-rpc` and are the responsibility of the orchestrate
/// layer in each protocol SDK.
///
/// **Address lookup tables are intentionally not part of the plan.** ALT
/// resolution is an I/O concern (the orchestrate layer fetches them from
/// chain via `TransactionBuilderRpcExt::add_lookup_table_by_address` after
/// `into_transaction_builder()`). Plan-layer code stays ALT-unaware so that
/// pure unit tests for `Plannable` impls don't need to mock or hand-roll
/// `AddressLookupTableAccount` values.
#[derive(Debug, Default)]
pub struct PlannedTransaction {
    /// The instructions to include in the transaction, in execution order.
    pub instructions: Vec<Instruction>,

    /// Additional signers required by the instructions (beyond the fee payer).
    ///
    /// Typical contents: an ephemeral position NFT mint keypair, a newly
    /// allocated account keypair, etc. The fee payer is *not* included here
    /// and is supplied separately by the orchestrate layer.
    pub additional_signers: Vec<Keypair>,
}

impl PlannedTransaction {
    /// Convenience constructor.
    pub fn new(instructions: Vec<Instruction>, additional_signers: Vec<Keypair>) -> Self {
        Self { instructions, additional_signers }
    }

    /// `true` if the planned transaction contains no instructions.
    ///
    /// Only the `instructions` vector is checked: a `PlannedTransaction`
    /// carrying additional signers but no instructions is considered
    /// degenerate (signers without instructions to consume them have no
    /// effect on chain) and not accounted for here.
    pub fn is_empty(&self) -> bool {
        self.instructions.is_empty()
    }

    /// Convert into a [`TransactionBuilder`] for downstream signing/sending.
    ///
    /// Consumes `self`. The returned builder carries the same instructions
    /// and additional signers, with an empty address lookup table list.
    pub fn into_transaction_builder(self) -> TransactionBuilder {
        TransactionBuilder::with_components(self.instructions, self.additional_signers)
    }
}

impl From<PlannedTransaction> for TransactionBuilder {
    fn from(planned: PlannedTransaction) -> Self {
        planned.into_transaction_builder()
    }
}

/// A pure planning contract: take a snapshot and parameters, emit a
/// [`PlannedTransaction`].
///
/// Implementors are hand-written handle types inside protocol SDKs
/// (`pub struct RaydiumAddLiquidity;`). They must never be implemented on
/// types that live under `src/generated/` (see Phase 1 design spec
/// architectural invariant 1).
///
/// The `plan` method is intentionally synchronous and side-effect-free:
/// if the implementation reaches for an RPC client or an async runtime,
/// the layering is wrong.
pub trait Plannable {
    /// The snapshot of on-chain state required to compute the plan.
    type Snapshot;

    /// The caller-supplied parameters (amounts, slippage, tick range, …).
    type Params;

    /// Errors produced by pure planning (validation, overflow, invalid range).
    ///
    /// The `std::error::Error + Send + Sync + 'static` bound lets the
    /// orchestrate layer box plan errors uniformly alongside fetch and send
    /// errors via the existing `OrchestrateError::Plan(#[from] PlanError)`
    /// path. All production `Plannable` impls satisfy this naturally by
    /// using `wp_solana_core::plan_error::PlanError`, which derives
    /// `thiserror::Error`.
    type Error: std::error::Error + Send + Sync + 'static;

    /// Compute the planned transaction for this operation.
    ///
    /// **Configuration note (token planning).** Workspace `Plannable` impls
    /// that internally call `wp_solana_core::token::*` planners must supply
    /// a `WorkspacePlanConfig`. Because this trait method's signature is
    /// fixed at `(snapshot, params)`, those impls hardcode
    /// `&WorkspacePlanConfig::default()` (Ata wrapping + balance enforcement).
    /// Callers that need a non-default config (e.g. `Keypair` wSOL or
    /// `enforce_balance = false`) MUST bypass this trait and call the
    /// underlying `plan_xxx(snapshot, params, &config)` fn directly. This
    /// trade-off is intentional: it keeps generic dispatch ergonomic for
    /// the common case while pushing custom configs to the explicit fn API.
    fn plan(
        snapshot: &Self::Snapshot,
        params: Self::Params,
    ) -> Result<PlannedTransaction, Self::Error>;
}

#[cfg(test)]
mod tests {
    use solana_sdk::{
        instruction::{AccountMeta, Instruction},
        pubkey::Pubkey,
    };

    use super::*;

    fn ix() -> Instruction {
        Instruction {
            program_id: Pubkey::new_unique(),
            accounts: vec![AccountMeta::new(Pubkey::new_unique(), false)],
            data: vec![9, 9, 9],
        }
    }

    #[test]
    fn default_is_empty() {
        let p = PlannedTransaction::default();
        assert!(p.is_empty());
    }

    #[test]
    fn new_populates_fields() {
        let p = PlannedTransaction::new(vec![ix()], vec![Keypair::new()]);
        assert!(!p.is_empty());
        assert_eq!(p.instructions.len(), 1);
        assert_eq!(p.additional_signers.len(), 1);
    }

    #[test]
    fn into_transaction_builder_round_trip() {
        let p = PlannedTransaction::new(vec![ix(), ix()], vec![]);
        let b: TransactionBuilder = p.into();
        assert_eq!(b.instruction_count(), 2);
        assert_eq!(b.signer_count(), 0);
    }

    /// A minimal `Plannable` impl on a local handle type demonstrates
    /// the contract without pulling in any protocol SDK. This test
    /// doubles as an executable example for future protocol migrations.
    #[test]
    fn plannable_trait_can_be_implemented_on_a_local_handle() {
        struct NoopPlan;
        struct NoopSnapshot;
        struct NoopParams;

        #[derive(Debug, thiserror::Error)]
        #[error("noop")]
        struct NoopError;

        impl Plannable for NoopPlan {
            type Error = NoopError;
            type Params = NoopParams;
            type Snapshot = NoopSnapshot;

            fn plan(
                _snapshot: &Self::Snapshot,
                _params: Self::Params,
            ) -> Result<PlannedTransaction, Self::Error> {
                Ok(PlannedTransaction::new(vec![ix()], vec![]))
            }
        }

        let planned = NoopPlan::plan(&NoopSnapshot, NoopParams).unwrap();
        assert_eq!(planned.instructions.len(), 1);
    }
}