From dac3fdde8be8404574843da976d7be080c0d76e5 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Sun, 25 Jan 2026 20:10:09 +0000 Subject: [PATCH 1/6] specs --- sdk-libs/client/docs/init-flow-comparison.md | 250 ++++++++++ .../docs/init-flow-design-a-manifest.md | 213 +++++++++ .../docs/init-flow-design-b-raw-inputs.md | 260 ++++++++++ .../client/docs/init-flow-design-ideal.md | 442 +++++++++++++++++ .../docs/init-flow-executive-summary.md | 118 +++++ .../client/docs/init-flow-final-design.md | 451 ++++++++++++++++++ sdk-libs/client/docs/init-flow-spec.md | 261 ++++++++++ .../client/docs/init-flow-visual-analysis.md | 395 +++++++++++++++ 8 files changed, 2390 insertions(+) create mode 100644 sdk-libs/client/docs/init-flow-comparison.md create mode 100644 sdk-libs/client/docs/init-flow-design-a-manifest.md create mode 100644 sdk-libs/client/docs/init-flow-design-b-raw-inputs.md create mode 100644 sdk-libs/client/docs/init-flow-design-ideal.md create mode 100644 sdk-libs/client/docs/init-flow-executive-summary.md create mode 100644 sdk-libs/client/docs/init-flow-final-design.md create mode 100644 sdk-libs/client/docs/init-flow-spec.md create mode 100644 sdk-libs/client/docs/init-flow-visual-analysis.md diff --git a/sdk-libs/client/docs/init-flow-comparison.md b/sdk-libs/client/docs/init-flow-comparison.md new file mode 100644 index 0000000000..70a583a87e --- /dev/null +++ b/sdk-libs/client/docs/init-flow-comparison.md @@ -0,0 +1,250 @@ +# Init Flow Design Comparison + +## Visual: Data Flow Diagrams + +### Current Design (v1 spec) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ CLIENT CODE │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 1. sdk.get_create_accounts_inputs(&instruction) │ │ +│ │ (returns Vec) │ │ +│ └────────────────────────┬─────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 2. get_create_accounts_proof(&rpc, &program_id, inputs) │ │ +│ │ (ASYNC - does address tree fetch + proof fetch) │ │ +│ └────────────────────────┬─────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 3. Build instruction with proof_result │ │ +│ │ (client must still derive PDAs separately!) │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + +Problems: +- PDAs derived twice (in SDK method + client instruction building) +- Client doesn't see intermediate types +- "Magic" inside get_create_accounts_proof +``` + +### Design A: Account Manifest + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ CLIENT CODE │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 1. AmmSdk::init_pool_manifest(config, m0, m1, creator)│ │ +│ │ SYNC - returns AccountManifest │ │ +│ │ ┌────────────────────────────────────────────┐ │ │ +│ │ │ entries: [ │ │ │ +│ │ │ { pool_state, AddressedPda, "..." } │ │ │ +│ │ │ { observation, AddressedPda, "..." } │ │ │ +│ │ │ { lp_mint_signer, AddressedMint, "..." } │ │ │ +│ │ │ { token_0_vault, TokenAccount, "..." } │ │ │ +│ │ │ { creator_lp, Ata, "..." } │ │ │ +│ │ │ ] │ │ │ +│ │ └────────────────────────────────────────────┘ │ │ +│ └────────────────────────┬─────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 2. manifest.to_proof_inputs() │ │ +│ │ SYNC - filters AddressedPda | AddressedMint │ │ +│ └────────────────────────┬─────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 3. get_create_accounts_proof(&rpc, &pid, inputs) │ │ +│ │ ASYNC │ │ +│ └────────────────────────┬─────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 4. Build instruction using manifest.get("pool_state")│ │ +│ │ SYNC - all pubkeys from manifest │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + +Advantages: +- All accounts visible with roles +- Single source of truth for pubkeys +- Filtering is explicit client decision +``` + +### Design B: Raw Inputs + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ CLIENT CODE │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 0. address_tree = rpc.get_address_tree_v2().tree │ │ +│ │ ASYNC - explicit dependency │ │ +│ └────────────────────────┬─────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 1. AmmSdk::derive_init_pool(..., &address_tree) │ │ +│ │ SYNC - returns DerivedPdas │ │ +│ │ ┌────────────────────────────────────────────┐ │ │ +│ │ │ pdas: InitPoolPdas { │ │ │ +│ │ │ pool_state, pool_state_bump, │ │ │ +│ │ │ observation_state, observation_state_bump│ │ │ +│ │ │ ...all pubkeys + bumps... │ │ │ +│ │ │ } │ │ │ +│ │ │ proof_addresses: RawAddressInputs { │ │ │ +│ │ │ new_addresses: [AddressWithTree, ...] │ │ │ +│ │ │ } │ │ │ +│ │ └────────────────────────────────────────────┘ │ │ +│ └────────────────────────┬─────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 2. rpc.get_validity_proof([], new_addresses, None) │ │ +│ │ ASYNC - using raw protocol types │ │ +│ └────────────────────────┬─────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 3. pack_proof(&pid, validity_proof, &state_tree_info)│ │ +│ │ SYNC │ │ +│ └────────────────────────┬─────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 4. derived.pdas.to_accounts(...).to_account_metas() │ │ +│ │ SYNC - type-safe instruction building │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + +Advantages: +- Zero abstraction over protocol types +- Full control over every RPC call +- Type-safe PDA struct with bumps +``` + +--- + +## Comparison Matrix + +| Criterion | v1 Spec | Design A | Design B | +|-----------|---------|----------|----------| +| **Transparency** | Low | High | Maximum | +| **Account visibility** | Partial | Full (with roles) | Full (with bumps) | +| **Hidden RPC** | Yes (in get_create_accounts_proof) | Partially | None | +| **Type safety** | Medium | Medium | High (typed PDA struct) | +| **Aggregator fit** | Medium | High | Maximum | +| **Client verbosity** | Low | Medium | Higher | +| **Bump access** | No | Optional | Built-in | +| **Debugging** | Hard | Easy (names) | Easy (types) | +| **Customization** | Low | Medium | Maximum | +| **Learning curve** | Low | Low | Medium | + +--- + +## Jupiter AMM Trait Comparison + +Jupiter's `Amm` trait: + +```rust +trait Amm { + fn from_keyed_account(keyed_account: &KeyedAccount, amm_context: &AmmContext) -> Result; + fn get_accounts_to_update(&self) -> Vec; + fn update(&mut self, account_map: &AccountMap) -> Result<()>; + fn quote(&self, quote_params: &QuoteParams) -> Result; + fn get_swap_and_account_metas(&self, swap_params: &SwapParams) -> Result; +} +``` + +Key patterns: +1. **Flat return types**: `Vec`, not nested abstractions +2. **Sync/Async split**: `get_accounts_to_update()` is sync, fetching is client's job +3. **Explicit update**: Client feeds data via `update()` +4. **Single responsibility**: Each method does one thing + +**Design A** aligns with Jupiter's `get_accounts_to_update()` pattern (flat list with metadata). + +**Design B** goes further, exposing raw protocol types for maximum control. + +--- + +## Aggregator Requirements Analysis + +### Jupiter Integration + +```rust +// Jupiter wants: +// 1. Know all accounts upfront (for simulation) +// 2. Batch fetches across multiple AMMs +// 3. Audit trail / logging + +// Design A fits well: +let manifest = AmmSdk::init_pool_manifest(&config, &m0, &m1, &creator); +jupiter_logger.log_accounts(&manifest.entries); + +// Design B fits well too: +let derived = AmmSdk::derive_init_pool(&config, &m0, &m1, &creator, &tree); +jupiter_logger.log_pdas(&derived.pdas); +``` + +### DFlow Integration + +```rust +// DFlow wants: +// 1. Deterministic address derivation +// 2. Proof batching across orders +// 3. Custom proof infrastructure + +// Design B is ideal: +let derived = AmmSdk::derive_init_pool(&config, &m0, &m1, &creator, &tree); + +// Batch proofs across multiple init operations +let all_addresses: Vec = orders + .iter() + .flat_map(|o| o.derived.proof_addresses.new_addresses.clone()) + .collect(); + +let batched_proof = dflow_prover.batch_proof(all_addresses).await?; +``` + +--- + +## Recommendation + +**For aggregators**: Design B (Raw Inputs) provides maximum control. + +**For typical clients**: Design A (Manifest) balances transparency with ease of use. + +**Hybrid approach**: Implement Design B as the foundation, provide Design A as a convenience layer: + +```rust +impl AccountManifest { + /// Create manifest from raw derived PDAs. + pub fn from_derived(derived: &DerivedPdas) -> Self { + AccountManifest { + entries: derived.pdas.into_manifest_entries(), + } + } +} +``` + +--- + +## Next Steps + +1. Validate designs against actual aggregator requirements +2. Prototype both designs with the AMM test +3. Get feedback from Jupiter/DFlow teams +4. Choose or hybridize based on real-world usage diff --git a/sdk-libs/client/docs/init-flow-design-a-manifest.md b/sdk-libs/client/docs/init-flow-design-a-manifest.md new file mode 100644 index 0000000000..471435b2e3 --- /dev/null +++ b/sdk-libs/client/docs/init-flow-design-a-manifest.md @@ -0,0 +1,213 @@ +# Init Flow Design A: Account Manifest Pattern + +## Philosophy + +**Jupiter-inspired**: Flat data structures, explicit account lists, no hidden magic. +**Transparency**: Every account is visible with its classification. +**Aggregator-friendly**: Easy to audit, log, or transform account lists. + +--- + +## Core Type: AccountManifest + +A simple struct listing all accounts an init instruction will touch: + +```rust +/// Classification of how an account participates in init. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InitAccountRole { + /// PDA that needs address proof (compressed address derivation) + AddressedPda, + /// Mint that needs address proof (uses MINT_ADDRESS_TREE) + AddressedMint, + /// Token account - NO address proof needed (uses ATA derivation) + TokenAccount, + /// ATA - NO address proof needed + Ata, + /// Signer account (creator, authority, etc.) + Signer, + /// Static account (program, system, rent, etc.) + Static, +} + +/// Single account entry in the manifest. +#[derive(Debug, Clone)] +pub struct ManifestEntry { + pub pubkey: Pubkey, + pub role: InitAccountRole, + /// Human-readable name for debugging/logging + pub name: &'static str, +} + +impl ManifestEntry { + /// Does this account need an address proof? + #[inline] + pub fn needs_address_proof(&self) -> bool { + matches!(self.role, InitAccountRole::AddressedPda | InitAccountRole::AddressedMint) + } +} + +/// Complete account manifest for an init instruction. +#[derive(Debug, Clone)] +pub struct AccountManifest { + pub entries: Vec, +} +``` + +--- + +## SDK Contract + +Each SDK provides a single function per init instruction: + +```rust +impl AmmSdk { + /// Returns complete account manifest for InitializePool. + /// + /// SYNC - no RPC. Pure derivation. + /// + /// All PDAs are derived. All roles are classified. + /// Client can inspect, filter, log, or transform. + pub fn init_pool_manifest( + amm_config: &Pubkey, + token_0_mint: &Pubkey, + token_1_mint: &Pubkey, + creator: &Pubkey, + ) -> AccountManifest { + let (pool_state, _) = derive_pool_state(amm_config, token_0_mint, token_1_mint); + let (observation_state, _) = derive_observation_state(&pool_state); + let (authority, _) = derive_authority(); + let (lp_mint_signer, _) = derive_lp_mint_signer(&pool_state); + let (lp_mint, _) = find_mint_address(&lp_mint_signer); + let (token_0_vault, _) = derive_token_vault(&pool_state, token_0_mint); + let (token_1_vault, _) = derive_token_vault(&pool_state, token_1_mint); + let (creator_lp_token, _) = get_associated_token_address_and_bump(creator, &lp_mint); + + AccountManifest { + entries: vec![ + ManifestEntry { pubkey: pool_state, role: InitAccountRole::AddressedPda, name: "pool_state" }, + ManifestEntry { pubkey: observation_state, role: InitAccountRole::AddressedPda, name: "observation_state" }, + ManifestEntry { pubkey: lp_mint_signer, role: InitAccountRole::AddressedMint, name: "lp_mint_signer" }, + ManifestEntry { pubkey: lp_mint, role: InitAccountRole::Static, name: "lp_mint" }, + ManifestEntry { pubkey: token_0_vault, role: InitAccountRole::TokenAccount, name: "token_0_vault" }, + ManifestEntry { pubkey: token_1_vault, role: InitAccountRole::TokenAccount, name: "token_1_vault" }, + ManifestEntry { pubkey: creator_lp_token, role: InitAccountRole::Ata, name: "creator_lp_token" }, + ManifestEntry { pubkey: *creator, role: InitAccountRole::Signer, name: "creator" }, + ManifestEntry { pubkey: authority, role: InitAccountRole::Static, name: "authority" }, + ], + } + } +} +``` + +--- + +## Client Flow + +```rust +// 1. Get manifest (SYNC) +let manifest = AmmSdk::init_pool_manifest(&config, &mint_0, &mint_1, &creator.pubkey()); + +// 2. Extract accounts needing proofs (simple filter) +let proof_inputs: Vec = manifest + .entries + .iter() + .filter(|e| e.needs_address_proof()) + .map(|e| match e.role { + InitAccountRole::AddressedPda => CreateAccountsProofInput::pda(e.pubkey), + InitAccountRole::AddressedMint => CreateAccountsProofInput::mint(e.pubkey), + _ => unreachable!(), + }) + .collect(); + +// 3. Get proof (ASYNC - only RPC call) +let proof_result = get_create_accounts_proof(&rpc, &program_id, proof_inputs).await?; + +// 4. Build instruction using manifest pubkeys +let ix = build_init_pool_ix(&manifest, &proof_result, init_params); +``` + +--- + +## Helper: Auto-Filter + +For clients that don't want manual filtering: + +```rust +impl AccountManifest { + /// Extract proof inputs for accounts that need address proofs. + pub fn to_proof_inputs(&self) -> Vec { + self.entries + .iter() + .filter_map(|e| match e.role { + InitAccountRole::AddressedPda => Some(CreateAccountsProofInput::pda(e.pubkey)), + InitAccountRole::AddressedMint => Some(CreateAccountsProofInput::mint(e.pubkey)), + _ => None, + }) + .collect() + } + + /// Get pubkey by name. + pub fn get(&self, name: &str) -> Option { + self.entries.iter().find(|e| e.name == name).map(|e| e.pubkey) + } +} +``` + +--- + +## Aggregator Usage (Jupiter/DFlow) + +```rust +// Jupiter integration - they want to see everything +let manifest = AmmSdk::init_pool_manifest(&config, &mint_0, &mint_1, &creator); + +// Log for debugging/audit +for entry in &manifest.entries { + log::info!("{}: {} ({:?})", entry.name, entry.pubkey, entry.role); +} + +// They control proof fetching +let proof_inputs = manifest.to_proof_inputs(); +if !proof_inputs.is_empty() { + let proof = get_create_accounts_proof(&rpc, &program_id, proof_inputs).await?; + // ... +} +``` + +--- + +## Trade-offs + +### Pros +- **Fully transparent**: Every account is visible and classified +- **Debuggable**: Names + roles make logging trivial +- **Flexible**: Aggregators can transform/filter as needed +- **No hidden state**: Pure function, no SDK instance needed +- **Jupiter-like**: Matches their `get_accounts_to_update()` pattern + +### Cons +- Manual mapping from manifest to instruction accounts (but explicit) +- Client still needs to understand PDA vs Mint vs Token distinction (but it's visible) +- Requires SDK to define roles correctly + +--- + +## Comparison with Current Design + +| Aspect | Current (v1 spec) | Design A (Manifest) | +|--------|-------------------|---------------------| +| Account visibility | Hidden in trait method | Fully exposed | +| Role classification | Implicit | Explicit enum | +| Debugging | Hard | Easy (names + roles) | +| Aggregator fit | Medium | High | +| Code verbosity | Lower | Slightly higher | +| Magic | Some | None | + +--- + +## Open Questions + +1. Should `AccountManifest` include bumps for each PDA? +2. Should we provide a `manifest.to_account_metas()` helper? +3. Should `name` be an enum instead of `&'static str`? diff --git a/sdk-libs/client/docs/init-flow-design-b-raw-inputs.md b/sdk-libs/client/docs/init-flow-design-b-raw-inputs.md new file mode 100644 index 0000000000..05d66ac947 --- /dev/null +++ b/sdk-libs/client/docs/init-flow-design-b-raw-inputs.md @@ -0,0 +1,260 @@ +# Init Flow Design B: Raw Inputs Pattern + +## Philosophy + +**Close to the metal**: Expose the exact data structures the protocol uses. +**Zero abstraction**: Client sees what the on-chain program sees. +**Aggregator-friendly**: Data flows linearly, easy to trace. + +--- + +## Core Principle + +Instead of abstracting proof inputs, expose the **raw protocol types** with helper derivation: + +```rust +/// Raw addresses for proof generation. +/// This is what get_validity_proof actually needs. +#[derive(Debug, Clone)] +pub struct RawAddressInputs { + /// Addresses that need non-inclusion proofs (new accounts) + pub new_addresses: Vec, +} + +/// Derived PDAs with their bumps. +/// Client needs bumps for instruction data. +#[derive(Debug, Clone)] +pub struct DerivedPdas { + /// Program-specific PDA struct with all addresses + bumps + pub pdas: T, + /// Which addresses need proofs (indices into pdas) + pub proof_addresses: RawAddressInputs, +} +``` + +--- + +## SDK Contract + +Each SDK defines its own strongly-typed PDA struct: + +```rust +/// All PDAs for InitializePool with bumps. +#[derive(Debug, Clone)] +pub struct InitPoolPdas { + pub pool_state: Pubkey, + pub pool_state_bump: u8, + pub observation_state: Pubkey, + pub observation_state_bump: u8, + pub authority: Pubkey, + pub authority_bump: u8, + pub lp_mint_signer: Pubkey, + pub lp_mint_signer_bump: u8, + pub lp_mint: Pubkey, + pub token_0_vault: Pubkey, + pub token_0_vault_bump: u8, + pub token_1_vault: Pubkey, + pub token_1_vault_bump: u8, + pub creator_lp_token: Pubkey, + pub creator_lp_token_bump: u8, +} + +impl AmmSdk { + /// Derive all PDAs and identify which need proofs. + /// + /// SYNC - no RPC. Returns raw protocol inputs. + /// + /// The `address_tree` is required because address derivation + /// depends on the tree. Get it from `rpc.get_address_tree_v2()`. + pub fn derive_init_pool( + amm_config: &Pubkey, + token_0_mint: &Pubkey, + token_1_mint: &Pubkey, + creator: &Pubkey, + address_tree: &Pubkey, // Client provides from RPC + ) -> DerivedPdas { + // Derive all PDAs... + let (pool_state, pool_state_bump) = derive_pool_state(...); + // ... other derivations ... + + let pdas = InitPoolPdas { + pool_state, + pool_state_bump, + // ... all fields ... + }; + + // Derive compressed addresses for accounts that need proofs + let pool_address = derive_address(&pool_state.to_bytes(), &address_tree.to_bytes(), &PROGRAM_ID.to_bytes()); + let obs_address = derive_address(&observation_state.to_bytes(), &address_tree.to_bytes(), &PROGRAM_ID.to_bytes()); + let mint_address = derive_mint_compressed_address(&lp_mint_signer, &MINT_ADDRESS_TREE); + + DerivedPdas { + pdas, + proof_addresses: RawAddressInputs { + new_addresses: vec![ + AddressWithTree { address: pool_address, tree: *address_tree }, + AddressWithTree { address: obs_address, tree: *address_tree }, + AddressWithTree { address: mint_address, tree: MINT_ADDRESS_TREE }, + ], + }, + } + } +} +``` + +--- + +## Client Flow + +```rust +// 1. Get address tree (single RPC call) +let address_tree = rpc.get_address_tree_v2().tree; + +// 2. Derive everything (SYNC) +let derived = AmmSdk::derive_init_pool(&config, &mint_0, &mint_1, &creator, &address_tree); + +// 3. Get validity proof using raw addresses (ASYNC) +let validity_proof = rpc + .get_validity_proof(vec![], derived.proof_addresses.new_addresses.clone(), None) + .await? + .value; + +// 4. Pack proof (SYNC) +let state_tree_info = rpc.get_random_state_tree_info()?; +let packed = pack_proof(&program_id, validity_proof.clone(), &state_tree_info, None)?; + +// 5. Build instruction with raw pdas and proof +let ix = build_init_pool_ix( + &derived.pdas, // Has all pubkeys + bumps + validity_proof.proof, + packed.address_trees[0], // Address tree info + packed.output_tree_index, +); +``` + +--- + +## Why Expose address_tree? + +The client already needs RPC for: +1. Getting validity proofs +2. Getting state tree info + +Making `address_tree` explicit: +- Shows the dependency clearly +- Allows caching (address trees rarely change) +- No hidden RPC in SDK + +```rust +// Client can cache address tree +let address_tree = match cached_address_tree { + Some(tree) => tree, + None => { + let tree = rpc.get_address_tree_v2().tree; + cache.set_address_tree(tree); + tree + } +}; +``` + +--- + +## Aggregator Usage (Jupiter/DFlow) + +```rust +// Jupiter wants raw control +let address_tree = rpc.get_address_tree_v2().tree; +let derived = AmmSdk::derive_init_pool(&config, &mint_0, &mint_1, &creator, &address_tree); + +// They can inspect raw addresses +println!("Pool compressed address: {:?}", derived.proof_addresses.new_addresses[0].address); +println!("Pool state PDA: {}", derived.pdas.pool_state); + +// They fetch proof their way (maybe batching with other proofs) +let validity_proof = their_proof_service.get_proof( + derived.proof_addresses.new_addresses.clone() +).await?; + +// Build instruction with their preferred method +let ix = their_ix_builder.build_init_pool(&derived.pdas, &validity_proof); +``` + +--- + +## Type Safety: Instruction Builder + +SDK can provide type-safe instruction building: + +```rust +impl InitPoolPdas { + /// Build InitializePool accounts struct. + pub fn to_accounts(&self, creator: &Pubkey, config: &Pubkey, mints: (&Pubkey, &Pubkey)) -> InitializePoolAccounts { + InitializePoolAccounts { + creator: *creator, + amm_config: *config, + authority: self.authority, + pool_state: self.pool_state, + token_0_mint: *mints.0, + token_1_mint: *mints.1, + lp_mint_signer: self.lp_mint_signer, + lp_mint: self.lp_mint, + token_0_vault: self.token_0_vault, + token_1_vault: self.token_1_vault, + observation_state: self.observation_state, + creator_lp_token: self.creator_lp_token, + // ... static accounts ... + } + } + + /// Build InitializeParams with proof data. + pub fn to_params(&self, proof: CreateAccountsProof, init_amount_0: u64, init_amount_1: u64) -> InitializeParams { + InitializeParams { + init_amount_0, + init_amount_1, + open_time: 0, + create_accounts_proof: proof, + lp_mint_signer_bump: self.lp_mint_signer_bump, + creator_lp_token_bump: self.creator_lp_token_bump, + authority_bump: self.authority_bump, + } + } +} +``` + +--- + +## Trade-offs + +### Pros +- **Zero abstraction**: Client sees exactly what protocol uses +- **Full control**: Client can batch, cache, or customize anything +- **Type-safe**: Program-specific PDA struct prevents errors +- **Predictable**: No hidden RPC, no magic derivation +- **Composable**: Raw types work with any proof fetching strategy + +### Cons +- More verbose client code (but explicit) +- Client must call `get_address_tree_v2()` explicitly +- Multiple steps vs one-liner (but each step is transparent) + +--- + +## Comparison with Design A + +| Aspect | Design A (Manifest) | Design B (Raw Inputs) | +|--------|---------------------|----------------------| +| Abstraction | Light (roles + names) | None | +| Type safety | Generic manifest | Program-specific structs | +| address_tree | Hidden | Explicit parameter | +| Bump access | Optional | Built-in | +| Proof building | Helper method | Raw protocol types | +| Client verbosity | Medium | Higher | +| Customization | Medium | Maximum | + +--- + +## Open Questions + +1. Should `DerivedPdas` include the address_tree used? +2. Should we provide a convenience wrapper for the 3-step proof fetch? +3. How to handle programs with variable number of init accounts? diff --git a/sdk-libs/client/docs/init-flow-design-ideal.md b/sdk-libs/client/docs/init-flow-design-ideal.md new file mode 100644 index 0000000000..a1bc15d49e --- /dev/null +++ b/sdk-libs/client/docs/init-flow-design-ideal.md @@ -0,0 +1,442 @@ +# Init Flow: Ideal Design + +## Analysis: What's Missing in A & B + +### Design A Gaps +1. No bumps - client can't build instruction params without re-deriving +2. Manifest uses `&'static str` names - not type-safe +3. `to_proof_inputs()` hides address derivation (still some magic) +4. No clear path to typed instruction building + +### Design B Gaps +1. Client must call `get_address_tree_v2()` before derivation +2. Multiple pack steps (validity proof -> pack -> instruction) +3. No name/role metadata for debugging +4. Higher verbosity for simple use cases + +### What Aggregators Actually Need +1. **All accounts in one place** (Jupiter audit requirement) +2. **Bumps included** (instruction building) +3. **Batch-friendly** (DFlow proof batching) +4. **Debuggable** (name/role for logging) +5. **No hidden RPC** (predictable latency) +6. **Raw access when needed** (custom proof infra) + +--- + +## Ideal Design: Typed Manifest with Raw Access + +Combine the best of both: + +```rust +//============================================================================ +// CORE TYPES - Raw protocol types, no abstraction +//============================================================================ + +/// Address with its tree - exactly what get_validity_proof needs. +/// Re-exported from light_client::indexer for convenience. +pub use light_client::indexer::AddressWithTree; + +/// Compressed address + proof metadata. +#[derive(Debug, Clone)] +pub struct AddressProofInput { + /// The compressed address bytes (32-byte hash) + pub address: [u8; 32], + /// The address tree this belongs to + pub tree: Pubkey, + /// Human-readable name for debugging + pub name: &'static str, +} + +impl AddressProofInput { + /// Convert to AddressWithTree for proof fetching. + #[inline] + pub fn to_address_with_tree(&self) -> AddressWithTree { + AddressWithTree { + address: self.address, + tree: self.tree, + } + } +} + +//============================================================================ +// PROGRAM-SPECIFIC: Each SDK defines its own typed PDA struct +//============================================================================ + +/// All derived accounts for InitializePool. +/// +/// This is the SINGLE SOURCE OF TRUTH for all pubkeys and bumps. +/// Generated by macro or hand-written per instruction. +#[derive(Debug, Clone)] +pub struct InitPoolAccounts { + // PDAs that need address proofs + pub pool_state: Pubkey, + pub pool_state_bump: u8, + pub observation_state: Pubkey, + pub observation_state_bump: u8, + + // Mint that needs address proof + pub lp_mint_signer: Pubkey, + pub lp_mint_signer_bump: u8, + pub lp_mint: Pubkey, // Derived from lp_mint_signer + + // Token accounts (NO address proof needed) + pub token_0_vault: Pubkey, + pub token_0_vault_bump: u8, + pub token_1_vault: Pubkey, + pub token_1_vault_bump: u8, + + // ATA (NO address proof needed) + pub creator_lp_token: Pubkey, + pub creator_lp_token_bump: u8, + + // Static PDAs (NO address proof needed) + pub authority: Pubkey, + pub authority_bump: u8, +} + +/// Output from derive function - accounts + proof inputs. +#[derive(Debug, Clone)] +pub struct InitPoolDerived { + /// All derived accounts with bumps. + pub accounts: InitPoolAccounts, + /// Addresses that need proofs (pre-computed). + /// Ready to pass to get_validity_proof. + pub proof_inputs: Vec, +} + +//============================================================================ +// SDK IMPLEMENTATION +//============================================================================ + +impl AmmSdk { + /// Derive all InitializePool accounts and proof inputs. + /// + /// SYNC - pure derivation, no RPC. + /// + /// # Arguments + /// * `address_tree` - From `rpc.get_address_tree_v2().tree`. + /// Pass `None` to skip compressed address derivation (only PDAs). + /// + /// # Returns + /// All accounts with bumps + proof inputs for accounts needing proofs. + pub fn derive_init_pool( + amm_config: &Pubkey, + token_0_mint: &Pubkey, + token_1_mint: &Pubkey, + creator: &Pubkey, + address_tree: &Pubkey, + ) -> InitPoolDerived { + // 1. Derive all PDAs + let (pool_state, pool_state_bump) = Pubkey::find_program_address( + &[POOL_SEED.as_bytes(), amm_config.as_ref(), token_0_mint.as_ref(), token_1_mint.as_ref()], + &PROGRAM_ID, + ); + let (observation_state, observation_state_bump) = Pubkey::find_program_address( + &[OBSERVATION_SEED.as_bytes(), pool_state.as_ref()], + &PROGRAM_ID, + ); + let (authority, authority_bump) = Pubkey::find_program_address( + &[AUTH_SEED.as_bytes()], + &PROGRAM_ID, + ); + let (lp_mint_signer, lp_mint_signer_bump) = Pubkey::find_program_address( + &[POOL_LP_MINT_SIGNER_SEED, pool_state.as_ref()], + &PROGRAM_ID, + ); + let (lp_mint, _) = find_mint_address(&lp_mint_signer); + let (token_0_vault, token_0_vault_bump) = Pubkey::find_program_address( + &[POOL_VAULT_SEED.as_bytes(), pool_state.as_ref(), token_0_mint.as_ref()], + &PROGRAM_ID, + ); + let (token_1_vault, token_1_vault_bump) = Pubkey::find_program_address( + &[POOL_VAULT_SEED.as_bytes(), pool_state.as_ref(), token_1_mint.as_ref()], + &PROGRAM_ID, + ); + let (creator_lp_token, creator_lp_token_bump) = get_associated_token_address_and_bump(creator, &lp_mint); + + let accounts = InitPoolAccounts { + pool_state, + pool_state_bump, + observation_state, + observation_state_bump, + authority, + authority_bump, + lp_mint_signer, + lp_mint_signer_bump, + lp_mint, + token_0_vault, + token_0_vault_bump, + token_1_vault, + token_1_vault_bump, + creator_lp_token, + creator_lp_token_bump, + }; + + // 2. Derive compressed addresses for accounts needing proofs + let pool_address = derive_address( + &pool_state.to_bytes(), + &address_tree.to_bytes(), + &PROGRAM_ID.to_bytes(), + ); + let observation_address = derive_address( + &observation_state.to_bytes(), + &address_tree.to_bytes(), + &PROGRAM_ID.to_bytes(), + ); + let mint_address = derive_mint_compressed_address(&lp_mint_signer, &MINT_ADDRESS_TREE_PUBKEY); + + let proof_inputs = vec![ + AddressProofInput { + address: pool_address, + tree: *address_tree, + name: "pool_state", + }, + AddressProofInput { + address: observation_address, + tree: *address_tree, + name: "observation_state", + }, + AddressProofInput { + address: mint_address, + tree: MINT_ADDRESS_TREE_PUBKEY, + name: "lp_mint", + }, + ]; + + InitPoolDerived { + accounts, + proof_inputs, + } + } +} + +//============================================================================ +// CLIENT HELPERS +//============================================================================ + +impl InitPoolDerived { + /// Get addresses ready for get_validity_proof. + pub fn addresses_with_trees(&self) -> Vec { + self.proof_inputs.iter().map(|p| p.to_address_with_tree()).collect() + } + + /// Log all proof inputs (for debugging/audit). + pub fn log_proof_inputs(&self) { + for input in &self.proof_inputs { + log::debug!( + "Proof input '{}': address={:?}, tree={}", + input.name, + input.address, + input.tree + ); + } + } +} + +impl InitPoolAccounts { + /// Build anchor accounts struct. + pub fn to_anchor_accounts( + &self, + creator: &Pubkey, + amm_config: &Pubkey, + token_0_mint: &Pubkey, + token_1_mint: &Pubkey, + config_pda: &Pubkey, + ) -> csdk_anchor_full_derived_test::accounts::InitializePool { + csdk_anchor_full_derived_test::accounts::InitializePool { + creator: *creator, + amm_config: *amm_config, + authority: self.authority, + pool_state: self.pool_state, + token_0_mint: *token_0_mint, + token_1_mint: *token_1_mint, + lp_mint_signer: self.lp_mint_signer, + lp_mint: self.lp_mint, + creator_lp_token: self.creator_lp_token, + token_0_vault: self.token_0_vault, + token_1_vault: self.token_1_vault, + observation_state: self.observation_state, + token_program: LIGHT_TOKEN_PROGRAM_ID, + token_0_program: LIGHT_TOKEN_PROGRAM_ID, + token_1_program: LIGHT_TOKEN_PROGRAM_ID, + associated_token_program: LIGHT_TOKEN_PROGRAM_ID, + system_program: solana_sdk::system_program::ID, + rent: solana_sdk::sysvar::rent::ID, + compression_config: *config_pda, + light_token_compressible_config: COMPRESSIBLE_CONFIG_V1, + rent_sponsor: RENT_SPONSOR_V1, + light_token_program: LIGHT_TOKEN_PROGRAM_ID, + light_token_cpi_authority: LIGHT_TOKEN_CPI_AUTHORITY, + } + } + + /// Build instruction params. + pub fn to_params( + &self, + create_accounts_proof: CreateAccountsProof, + init_amount_0: u64, + init_amount_1: u64, + ) -> InitializeParams { + InitializeParams { + init_amount_0, + init_amount_1, + open_time: 0, + create_accounts_proof, + lp_mint_signer_bump: self.lp_mint_signer_bump, + creator_lp_token_bump: self.creator_lp_token_bump, + authority_bump: self.authority_bump, + } + } +} +``` + +--- + +## Client Flow: Simple Case + +```rust +// 1. Get address tree (cache this!) +let address_tree = rpc.get_address_tree_v2().tree; + +// 2. Derive everything (SYNC) +let derived = AmmSdk::derive_init_pool(&config, &mint_0, &mint_1, &creator, &address_tree); + +// 3. Get proof using raw addresses (ASYNC) +let proof_result = get_create_accounts_proof_from_addresses( + &rpc, + &program_id, + derived.addresses_with_trees(), +).await?; + +// 4. Build instruction (SYNC) - uses accounts struct directly +let accounts = derived.accounts.to_anchor_accounts(&creator, &config, &mint_0, &mint_1, &config_pda); +let params = derived.accounts.to_params(proof_result.create_accounts_proof, 1000, 1000); + +let ix = Instruction { + program_id, + accounts: [accounts.to_account_metas(None), proof_result.remaining_accounts].concat(), + data: csdk_anchor_full_derived_test::instruction::InitializePool { params }.data(), +}; +``` + +--- + +## Client Flow: Aggregator (Full Control) + +```rust +// Jupiter/DFlow want maximum control + +// 1. Get address tree (they cache it) +let address_tree = jupiter_cache.get_or_fetch_address_tree(&rpc).await; + +// 2. Derive (SYNC) +let derived = AmmSdk::derive_init_pool(&config, &mint_0, &mint_1, &creator, &address_tree); + +// 3. Log for audit trail +derived.log_proof_inputs(); + +// 4. Batch with other operations +let all_addresses: Vec = [ + derived.addresses_with_trees(), + other_operation.addresses_with_trees(), +].concat(); + +// 5. Fetch proof their way +let validity_proof = jupiter_prover.batch_proof(all_addresses).await?; + +// 6. Pack manually +let state_tree = rpc.get_random_state_tree_info()?; +let packed = pack_proof(&program_id, validity_proof, &state_tree, None)?; + +// 7. Build instruction their way +let ix = jupiter_ix_builder.build( + &derived.accounts, + packed, + init_amounts, +); +``` + +--- + +## Convenience Wrapper (Optional) + +For clients who want one-liner: + +```rust +/// Convenience: derive + get proof in one call. +/// +/// Use this for simple cases. Aggregators should use raw derive + proof separately. +pub async fn derive_and_prove_init_pool( + rpc: &R, + amm_config: &Pubkey, + token_0_mint: &Pubkey, + token_1_mint: &Pubkey, + creator: &Pubkey, +) -> Result<(InitPoolDerived, CreateAccountsProofResult), Error> { + let address_tree = rpc.get_address_tree_v2().tree; + let derived = AmmSdk::derive_init_pool(amm_config, token_0_mint, token_1_mint, creator, &address_tree); + let proof_result = get_create_accounts_proof_from_addresses( + rpc, + &PROGRAM_ID, + derived.addresses_with_trees(), + ).await?; + Ok((derived, proof_result)) +} +``` + +--- + +## Key Design Decisions + +### 1. address_tree as explicit parameter +- No hidden RPC in derivation +- Cacheable by aggregators +- Clear dependency + +### 2. Typed PDA struct (not generic manifest) +- Type safety for instruction building +- IDE autocompletion +- Compile-time errors for missing fields + +### 3. proof_inputs with names +- Debuggable +- Audit trail +- Zero overhead (just metadata) + +### 4. Raw AddressWithTree access +- Protocol-native types +- Batchable +- No abstraction layer + +### 5. Convenience layer is optional +- Power users use raw derive +- Simple clients use wrapper +- No forced abstraction + +--- + +## Comparison with A & B + +| Aspect | Design A | Design B | Ideal | +|--------|----------|----------|-------| +| Typed accounts | No (generic) | Yes | Yes | +| Bumps included | No | Yes | Yes | +| Debug names | Yes | No | Yes | +| Raw address access | Partial | Yes | Yes | +| Convenience wrapper | Yes | No | Yes (optional) | +| Hidden RPC | Partial | No | No | +| Aggregator fit | High | Maximum | Maximum | +| Simple client fit | High | Medium | High | + +--- + +## Implementation Checklist + +1. [ ] Define `AddressProofInput` in `light_client::interface` +2. [ ] Add `get_create_accounts_proof_from_addresses()` function +3. [ ] Generate typed account structs per instruction (macro or codegen) +4. [ ] Add `to_anchor_accounts()` and `to_params()` helpers +5. [ ] Add optional convenience wrapper +6. [ ] Update AMM test to use new flow +7. [ ] Document for aggregators diff --git a/sdk-libs/client/docs/init-flow-executive-summary.md b/sdk-libs/client/docs/init-flow-executive-summary.md new file mode 100644 index 0000000000..077aa69189 --- /dev/null +++ b/sdk-libs/client/docs/init-flow-executive-summary.md @@ -0,0 +1,118 @@ +# Init Flow Design: Executive Summary + +## The Problem + +Clients initializing Light Protocol accounts must currently: +1. Manually derive all PDAs (50+ lines) +2. Know protocol rules: "PDAs and Mints need proofs, token accounts don't" +3. Correctly select `pda()` vs `mint()` for each account +4. Build instructions by manually wiring pubkeys + +**Result**: Error-prone, protocol knowledge leaked to clients. + +--- + +## Designs Evaluated + +| Design | Core Idea | Aggregator Fit | Simple Client Fit | +|--------|-----------|----------------|-------------------| +| **v1 Spec** | Trait method returns proof inputs | Medium | High | +| **A: Manifest** | Flat list with roles + names | High | High | +| **B: Raw Inputs** | Protocol-native types, explicit tree | Maximum | Medium | +| **Ideal** | Typed structs + raw access | Maximum | High | + +--- + +## Recommendation: Ideal Design + +Combine typed structs (compile-time safety) with raw address access (aggregator control). + +### API Surface + +```rust +// SDK provides per-instruction typed struct +let accounts = InitPoolAccounts::derive(&config, &m0, &m1, &creator, &address_tree); + +// Access proof inputs (pre-selected by SDK) +accounts.addresses_with_trees() // -> Vec +accounts.log_proof_inputs() // -> debug output with names + +// Type-safe instruction building +accounts.to_anchor_accounts(...) +accounts.to_params(proof, ...) +``` + +### Client Flow + +``` +┌──────────────────────────────┐ +│ 1. rpc.get_address_tree_v2() │ ASYNC (cache this) +└──────────────┬───────────────┘ + │ + ▼ +┌──────────────────────────────┐ +│ 2. Accounts::derive(...) │ SYNC +└──────────────┬───────────────┘ + │ + ▼ +┌──────────────────────────────┐ +│ 3. get_proof_for_addresses() │ ASYNC +└──────────────┬───────────────┘ + │ + ▼ +┌──────────────────────────────┐ +│ 4. Build instruction │ SYNC (type-safe) +└──────────────────────────────┘ +``` + +--- + +## Key Design Decisions + +| Decision | Why | +|----------|-----| +| `address_tree` as explicit param | No hidden RPC, cacheable | +| Typed struct (not generic manifest) | Compile-time safety, IDE support | +| Bumps included | Required for instruction params | +| AddressProofInput with names | Debugging, audit trail | +| `addresses_with_trees()` method | Raw protocol types for batching | +| `to_anchor_accounts()` helper | Type-safe instruction building | + +--- + +## Aggregator Benefits + +**Jupiter**: +- Batch addresses across multiple AMMs +- Custom proof infrastructure +- Audit logging with names + +**DFlow**: +- SYNC derivation for parallel processing +- Raw addresses for custom provers +- Full control over RPC patterns + +--- + +## Migration Path + +1. Add `AddressProofInput` type to `light_client::interface` +2. Add `get_proof_for_addresses()` function +3. SDK teams implement typed account structs per instruction +4. Update tests to use new pattern +5. Document for aggregators + +--- + +## Documents + +| File | Purpose | +|------|---------| +| `init-flow-spec.md` | Original v1 spec (reference) | +| `init-flow-design-a-manifest.md` | Design A details | +| `init-flow-design-b-raw-inputs.md` | Design B details | +| `init-flow-comparison.md` | Side-by-side comparison | +| `init-flow-visual-analysis.md` | Flow diagrams + gap analysis | +| `init-flow-ideal.md` | Combined ideal design | +| `init-flow-final-design.md` | Battle-tested implementation | +| `init-flow-executive-summary.md` | This document | diff --git a/sdk-libs/client/docs/init-flow-final-design.md b/sdk-libs/client/docs/init-flow-final-design.md new file mode 100644 index 0000000000..1513207696 --- /dev/null +++ b/sdk-libs/client/docs/init-flow-final-design.md @@ -0,0 +1,451 @@ +# Init Flow: Final Battle-Tested Design + +## Design Principles + +1. **SYNC derivation, ASYNC proof** - Clear separation +2. **Typed structs over generics** - Compile-time safety +3. **Raw access always available** - Aggregators need control +4. **Convenience is optional** - Don't force abstraction +5. **Debuggable by default** - Names everywhere + +--- + +## Core API + +### 1. Client-Side Types (in light_client::interface) + +```rust +/// Address with tree info for proof fetching. +/// Re-export from indexer for convenience. +pub use crate::indexer::AddressWithTree; + +/// Address proof input with debug name. +#[derive(Debug, Clone)] +pub struct AddressProofInput { + pub address: [u8; 32], + pub tree: Pubkey, + pub name: &'static str, +} + +impl AddressProofInput { + pub fn to_address_with_tree(&self) -> AddressWithTree { + AddressWithTree { address: self.address, tree: self.tree } + } +} + +/// Trait for SDK-generated init account structs. +pub trait InitAccounts: Sized { + /// Get proof inputs (addresses that need non-inclusion proofs). + fn proof_inputs(&self) -> &[AddressProofInput]; + + /// Get addresses ready for get_validity_proof. + fn addresses_with_trees(&self) -> Vec { + self.proof_inputs().iter().map(|p| p.to_address_with_tree()).collect() + } +} +``` + +### 2. New Proof Function (in light_client::interface) + +```rust +/// Get proof using pre-derived addresses. +/// +/// Use this with addresses from `InitAccounts::addresses_with_trees()`. +/// For aggregators who want to batch addresses from multiple operations. +pub async fn get_proof_for_addresses( + rpc: &R, + program_id: &Pubkey, + addresses: Vec, +) -> Result { + if addresses.is_empty() { + return empty_proof_result(rpc).await; + } + + let validity_proof = rpc + .get_validity_proof(vec![], addresses, None) + .await? + .value; + + let state_tree_info = rpc + .get_random_state_tree_info() + .map_err(CreateAccountsProofError::Rpc)?; + + let has_mints = addresses.iter().any(|a| a.tree == MINT_ADDRESS_TREE_PUBKEY); + let cpi_context = if has_mints { state_tree_info.cpi_context } else { None }; + + let packed = if has_mints { + pack_proof_for_mints(program_id, validity_proof.clone(), &state_tree_info, cpi_context)? + } else { + pack_proof(program_id, validity_proof.clone(), &state_tree_info, cpi_context)? + }; + + Ok(CreateAccountsProofResult { + create_accounts_proof: CreateAccountsProof { + proof: validity_proof.proof, + address_tree_info: packed.packed_tree_infos.address_trees.first().copied() + .ok_or(CreateAccountsProofError::EmptyInputs)?, + output_state_tree_index: packed.output_tree_index, + state_tree_index: packed.state_tree_index, + }, + remaining_accounts: packed.remaining_accounts, + }) +} +``` + +--- + +## SDK Implementation Pattern + +Each program SDK defines its own typed structs: + +### AMM SDK Example + +```rust +//! csdk_anchor_full_derived_test_sdk/src/init_pool.rs + +use light_client::interface::{AddressProofInput, InitAccounts}; +use solana_pubkey::Pubkey; + +/// All accounts for InitializePool with bumps. +#[derive(Debug, Clone)] +pub struct InitPoolAccounts { + // Accounts that need address proofs + pub pool_state: Pubkey, + pub pool_state_bump: u8, + pub observation_state: Pubkey, + pub observation_state_bump: u8, + pub lp_mint_signer: Pubkey, + pub lp_mint_signer_bump: u8, + + // Derived accounts (no proof needed but required for instruction) + pub lp_mint: Pubkey, + pub token_0_vault: Pubkey, + pub token_0_vault_bump: u8, + pub token_1_vault: Pubkey, + pub token_1_vault_bump: u8, + pub creator_lp_token: Pubkey, + pub creator_lp_token_bump: u8, + pub authority: Pubkey, + pub authority_bump: u8, + + // Pre-computed proof inputs (set during derivation) + proof_inputs: Vec, +} + +impl InitAccounts for InitPoolAccounts { + fn proof_inputs(&self) -> &[AddressProofInput] { + &self.proof_inputs + } +} + +impl InitPoolAccounts { + /// Derive all accounts for InitializePool. + /// + /// # Arguments + /// * `address_tree` - From `rpc.get_address_tree_v2().tree` + pub fn derive( + amm_config: &Pubkey, + token_0_mint: &Pubkey, + token_1_mint: &Pubkey, + creator: &Pubkey, + address_tree: &Pubkey, + ) -> Self { + // Derive PDAs + let (pool_state, pool_state_bump) = Pubkey::find_program_address( + &[POOL_SEED.as_bytes(), amm_config.as_ref(), + token_0_mint.as_ref(), token_1_mint.as_ref()], + &PROGRAM_ID, + ); + let (observation_state, observation_state_bump) = Pubkey::find_program_address( + &[OBSERVATION_SEED.as_bytes(), pool_state.as_ref()], + &PROGRAM_ID, + ); + let (authority, authority_bump) = Pubkey::find_program_address( + &[AUTH_SEED.as_bytes()], + &PROGRAM_ID, + ); + let (lp_mint_signer, lp_mint_signer_bump) = Pubkey::find_program_address( + &[POOL_LP_MINT_SIGNER_SEED, pool_state.as_ref()], + &PROGRAM_ID, + ); + let (lp_mint, _) = find_mint_address(&lp_mint_signer); + let (token_0_vault, token_0_vault_bump) = Pubkey::find_program_address( + &[POOL_VAULT_SEED.as_bytes(), pool_state.as_ref(), token_0_mint.as_ref()], + &PROGRAM_ID, + ); + let (token_1_vault, token_1_vault_bump) = Pubkey::find_program_address( + &[POOL_VAULT_SEED.as_bytes(), pool_state.as_ref(), token_1_mint.as_ref()], + &PROGRAM_ID, + ); + let (creator_lp_token, creator_lp_token_bump) = + get_associated_token_address_and_bump(creator, &lp_mint); + + // Derive compressed addresses for accounts needing proofs + let pool_address = derive_address( + &pool_state.to_bytes(), + &address_tree.to_bytes(), + &PROGRAM_ID.to_bytes(), + ); + let observation_address = derive_address( + &observation_state.to_bytes(), + &address_tree.to_bytes(), + &PROGRAM_ID.to_bytes(), + ); + let mint_address = derive_mint_compressed_address( + &lp_mint_signer, + &MINT_ADDRESS_TREE_PUBKEY, + ); + + let proof_inputs = vec![ + AddressProofInput { + address: pool_address, + tree: *address_tree, + name: "pool_state", + }, + AddressProofInput { + address: observation_address, + tree: *address_tree, + name: "observation_state", + }, + AddressProofInput { + address: mint_address, + tree: MINT_ADDRESS_TREE_PUBKEY, + name: "lp_mint", + }, + ]; + + Self { + pool_state, pool_state_bump, + observation_state, observation_state_bump, + lp_mint_signer, lp_mint_signer_bump, + lp_mint, + token_0_vault, token_0_vault_bump, + token_1_vault, token_1_vault_bump, + creator_lp_token, creator_lp_token_bump, + authority, authority_bump, + proof_inputs, + } + } + + /// Build Anchor accounts struct. + pub fn to_anchor_accounts( + &self, + creator: &Pubkey, + amm_config: &Pubkey, + token_0_mint: &Pubkey, + token_1_mint: &Pubkey, + config_pda: &Pubkey, + ) -> InitializePool { + InitializePool { + creator: *creator, + amm_config: *amm_config, + authority: self.authority, + pool_state: self.pool_state, + token_0_mint: *token_0_mint, + token_1_mint: *token_1_mint, + lp_mint_signer: self.lp_mint_signer, + lp_mint: self.lp_mint, + creator_lp_token: self.creator_lp_token, + token_0_vault: self.token_0_vault, + token_1_vault: self.token_1_vault, + observation_state: self.observation_state, + token_program: LIGHT_TOKEN_PROGRAM_ID, + // ... other static accounts ... + compression_config: *config_pda, + } + } + + /// Build instruction params with proof. + pub fn to_params( + &self, + proof: CreateAccountsProof, + init_amount_0: u64, + init_amount_1: u64, + ) -> InitializeParams { + InitializeParams { + init_amount_0, + init_amount_1, + open_time: 0, + create_accounts_proof: proof, + lp_mint_signer_bump: self.lp_mint_signer_bump, + creator_lp_token_bump: self.creator_lp_token_bump, + authority_bump: self.authority_bump, + } + } + + /// Log proof inputs for debugging. + pub fn log_proof_inputs(&self) { + for input in &self.proof_inputs { + log::debug!("{}: {:?} (tree: {})", input.name, input.address, input.tree); + } + } +} +``` + +--- + +## Client Usage + +### Simple Client + +```rust +// 1. Get address tree +let address_tree = rpc.get_address_tree_v2().tree; + +// 2. Derive accounts (SYNC) +let accounts = InitPoolAccounts::derive(&config, &mint_0, &mint_1, &creator, &address_tree); + +// 3. Get proof (ASYNC) +let proof_result = get_proof_for_addresses( + &rpc, + &PROGRAM_ID, + accounts.addresses_with_trees(), +).await?; + +// 4. Build instruction (SYNC) +let anchor_accounts = accounts.to_anchor_accounts(&creator, &config, &mint_0, &mint_1, &config_pda); +let params = accounts.to_params(proof_result.create_accounts_proof, 1000, 1000); + +let ix = Instruction { + program_id: PROGRAM_ID, + accounts: [anchor_accounts.to_account_metas(None), proof_result.remaining_accounts].concat(), + data: instruction::InitializePool { params }.data(), +}; +``` + +### Jupiter Integration + +```rust +impl LightAmm for LightProtocolAmm { + /// Jupiter's standardized interface. + fn get_accounts_to_update(&self) -> Vec { + // Return accounts Jupiter needs to fetch for quotes + vec![self.pool_state, self.observation_state] + } + + /// For init operations, Jupiter needs to derive first. + fn derive_init_accounts(&self, address_tree: &Pubkey) -> Box { + Box::new(InitPoolAccounts::derive( + &self.config, &self.mint_0, &self.mint_1, &self.creator, address_tree + )) + } +} + +// Jupiter's aggregation loop +async fn aggregate_inits(operations: &[InitOp]) -> Result> { + let tree = cache.get_address_tree().await; + + // Derive all (parallel, SYNC) + let accounts: Vec<_> = operations + .par_iter() + .map(|op| op.amm.derive_init_accounts(&tree)) + .collect(); + + // Batch all addresses + let all_addresses: Vec<_> = accounts + .iter() + .flat_map(|a| a.addresses_with_trees()) + .collect(); + + // Single batch proof (efficient) + let proof = jupiter_prover.batch(all_addresses).await?; + + // Build instructions + // ... split proof result back to individual operations ... +} +``` + +### DFlow Integration + +```rust +// DFlow processes orders through their pipeline +async fn process_order(order: &InitOrder) -> Result { + // 1. Derive (SYNC - fast, cacheable) + let accounts = InitPoolAccounts::derive( + &order.config, &order.mint_0, &order.mint_1, + &order.creator, &order.address_tree + ); + + // 2. Log for compliance/audit + accounts.log_proof_inputs(); + + // 3. Generate proof with DFlow's infrastructure + let proof = dflow_prover::generate(accounts.addresses_with_trees()).await?; + + // 4. Build transaction + let ix = build_init_ix(&accounts, proof); + Ok(Transaction::new(&[ix], &order.signers)) +} +``` + +--- + +## Comparison: Before vs After + +### Before (Current Test Code) + +```rust +// 50+ lines of PDA derivation +let pdas = derive_amm_pdas(&program_id, &config, &mint_0, &mint_1, &creator); + +// Client must know which accounts need proofs +let proof = get_create_accounts_proof(&rpc, &program_id, vec![ + CreateAccountsProofInput::pda(pdas.pool_state), // Must know this needs proof + CreateAccountsProofInput::pda(pdas.observation_state), // Must know this too + CreateAccountsProofInput::mint(pdas.lp_mint_signer), // And this is a mint + // Don't include token_0_vault - that's a token account! + // Don't include creator_lp_token - that's an ATA! +]).await?; + +// Manual instruction building - repeat all pubkeys +let accounts = InitializePool { + creator: creator.pubkey(), + amm_config: config, + pool_state: pdas.pool_state, // Manual + observation_state: pdas.observation_state, // Manual + // ... 15 more fields ... +}; +``` + +### After (Final Design) + +```rust +// Get tree once (cache it) +let tree = rpc.get_address_tree_v2().tree; + +// Derive everything (SYNC) +let accounts = InitPoolAccounts::derive(&config, &mint_0, &mint_1, &creator, &tree); + +// Get proof - SDK knows what needs proofs +let proof = get_proof_for_addresses(&rpc, &PROGRAM_ID, accounts.addresses_with_trees()).await?; + +// Type-safe instruction building +let anchor_accounts = accounts.to_anchor_accounts(&creator, &config, &mint_0, &mint_1, &cfg); +let params = accounts.to_params(proof.create_accounts_proof, 1000, 1000); +let ix = build_ix(anchor_accounts, params, proof.remaining_accounts); +``` + +--- + +## Checklist: What This Design Achieves + +| Requirement | Status | +|-------------|--------| +| SDK handles proof input selection | Yes - in derive() | +| Typed PDA struct with bumps | Yes - InitPoolAccounts | +| No hidden RPC in derivation | Yes - address_tree is param | +| Debug names for addresses | Yes - AddressProofInput.name | +| Batchable for aggregators | Yes - addresses_with_trees() | +| Type-safe instruction building | Yes - to_anchor_accounts() | +| Simple client path | Yes - 4 steps | +| Advanced aggregator path | Yes - raw access | +| Jupiter-like flat API | Yes - InitAccounts trait | + +--- + +## Files to Create/Modify + +1. `sdk-libs/client/src/interface/init_accounts.rs` - Core types +2. `sdk-libs/client/src/interface/mod.rs` - Export new types +3. `sdk-tests/csdk-anchor-full-derived-test-sdk/src/init_pool.rs` - Example impl +4. `sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs` - Updated test diff --git a/sdk-libs/client/docs/init-flow-spec.md b/sdk-libs/client/docs/init-flow-spec.md new file mode 100644 index 0000000000..63a79862d6 --- /dev/null +++ b/sdk-libs/client/docs/init-flow-spec.md @@ -0,0 +1,261 @@ +# Init Flow Simplification Spec (v2) + +## Problem Statement + +When initializing Light Protocol accounts, clients must: +1. Manually derive all PDAs +2. Know which accounts need address proofs (only PDAs and Mints - NOT token accounts or ATAs) +3. Call `get_create_accounts_proof` with exactly the right subset + +This is error-prone. Clients shouldn't need to know protocol internals. + +--- + +## Design Goals + +1. **Sync SDK** - All SDK methods are synchronous (no RPC calls) +2. **Instruction-based** - Use `Instruction` enum like existing trait methods +3. **Fast exit** - If no address proofs needed, client can skip RPC entirely +4. **Minimal indirection** - Reuse existing `CreateAccountsProofInput` type +5. **Consistent** - Follow existing `LightProgramInterface` patterns + +--- + +## Proposed Solution + +### Extend `LightProgramInterface` Trait + +Add one method to the existing trait: + +```rust +pub trait LightProgramInterface: Sized { + // ... existing methods ... + + /// Returns inputs needed for `get_create_accounts_proof` for an init instruction. + /// + /// Returns `Vec` containing ONLY accounts that need + /// address proofs (PDAs, Mints). Token accounts and ATAs are excluded. + /// + /// Returns empty vec if instruction creates no new addressed accounts + /// (client can skip proof RPC call entirely). + #[must_use] + fn get_create_accounts_inputs(&self, ix: &Self::Instruction) -> Vec; +} +``` + +### Client Flow + +```rust +// 1. SDK returns proof inputs (SYNC - no RPC) +let inputs = sdk.get_create_accounts_inputs(&AmmInstruction::InitializePool { + amm_config, + token_0_mint, + token_1_mint, + creator, +}); + +// 2. Fast exit if no proofs needed +let proof_result = if inputs.is_empty() { + // No address proofs needed - skip RPC + CreateAccountsProofResult::empty() +} else { + // Client does RPC call + get_create_accounts_proof(&rpc, &program_id, inputs).await? +}; + +// 3. Build instruction with proof +``` + +### Helper for Fast Exit + +```rust +impl CreateAccountsProofResult { + /// Empty result for instructions that don't create new addressed accounts. + pub fn empty() -> Self { + Self { + create_accounts_proof: CreateAccountsProof::default(), + remaining_accounts: vec![], + } + } +} + +/// Convenience wrapper that handles empty case. +pub async fn get_create_accounts_proof_if_needed( + rpc: &R, + program_id: &Pubkey, + inputs: Vec, +) -> Result { + if inputs.is_empty() { + return Ok(CreateAccountsProofResult::empty()); + } + get_create_accounts_proof(rpc, program_id, inputs).await +} +``` + +--- + +## Example Implementation (AmmSdk) + +### Instruction Enum with Init Params + +```rust +#[derive(Debug, Clone)] +pub enum AmmInstruction { + /// Initialize a new pool + InitializePool { + amm_config: Pubkey, + token_0_mint: Pubkey, + token_1_mint: Pubkey, + creator: Pubkey, + }, + Swap, + Deposit, + Withdraw, +} +``` + +### Trait Implementation + +```rust +impl LightProgramInterface for AmmSdk { + type Instruction = AmmInstruction; + // ... other types ... + + fn get_create_accounts_inputs(&self, ix: &Self::Instruction) -> Vec { + match ix { + AmmInstruction::InitializePool { + amm_config, + token_0_mint, + token_1_mint, + .. + } => { + // Derive PDAs that need address proofs + let (pool_state, _) = Pubkey::find_program_address( + &[POOL_SEED.as_bytes(), amm_config.as_ref(), + token_0_mint.as_ref(), token_1_mint.as_ref()], + &PROGRAM_ID, + ); + + let (observation_state, _) = Pubkey::find_program_address( + &[OBSERVATION_SEED.as_bytes(), pool_state.as_ref()], + &PROGRAM_ID, + ); + + let (lp_mint_signer, _) = Pubkey::find_program_address( + &[POOL_LP_MINT_SIGNER_SEED, pool_state.as_ref()], + &PROGRAM_ID, + ); + + // Return ONLY accounts needing address proofs + // Token vaults and ATAs are NOT included + vec![ + CreateAccountsProofInput::pda(pool_state), + CreateAccountsProofInput::pda(observation_state), + CreateAccountsProofInput::mint(lp_mint_signer), + ] + } + // Non-init instructions don't create new addressed accounts + AmmInstruction::Swap | + AmmInstruction::Deposit | + AmmInstruction::Withdraw => vec![], + } + } + + // ... other methods ... +} +``` + +### SDK Helper for Full PDA Derivation (Optional) + +SDKs can still provide a helper for clients that need all PDAs + bumps: + +```rust +impl AmmSdk { + /// Derive all PDAs for InitializePool (sync, no RPC). + /// Returns addresses AND bumps for instruction building. + pub fn derive_init_pool_pdas( + amm_config: &Pubkey, + token_0_mint: &Pubkey, + token_1_mint: &Pubkey, + creator: &Pubkey, + ) -> AmmPdas { + // ... full derivation with bumps ... + } +} +``` + +--- + +## Client Usage + +### Before (Current) + +```rust +// Client must know which accounts need proofs +let proof = get_create_accounts_proof(&rpc, &program_id, vec![ + CreateAccountsProofInput::pda(pdas.pool_state), + CreateAccountsProofInput::pda(pdas.observation_state), + CreateAccountsProofInput::mint(pdas.lp_mint_signer), + // Must NOT include vaults, ATAs! +]).await?; +``` + +### After (Proposed) + +```rust +// SDK tells client exactly what's needed (SYNC) +let inputs = sdk.get_create_accounts_inputs(&AmmInstruction::InitializePool { + amm_config: config, + token_0_mint: mint_0, + token_1_mint: mint_1, + creator: creator.pubkey(), +}); + +// Client does RPC (or skips if empty) +let proof = get_create_accounts_proof_if_needed(&rpc, &program_id, inputs).await?; +``` + +--- + +## Design Rationale + +### Why Extend Existing Trait? + +- Consistent with `get_accounts_to_update`, `get_specs_for_instruction` +- No new trait to implement +- Instruction enum already exists + +### Why Use `CreateAccountsProofInput` Directly? + +- No new types needed +- Client already uses this for `get_create_accounts_proof` +- SDK just filters/derives correctly + +### Why Return Empty Vec (Not Option)? + +- Simpler API +- Empty vec naturally flows to "skip RPC" logic +- Consistent with other methods that return `Vec` + +### Why Sync Only? + +- SDK is pure derivation logic +- RPC calls belong in client code +- Easier to test, compose, debug + +--- + +## Summary + +Single addition to `LightProgramInterface`: + +```rust +fn get_create_accounts_inputs(&self, ix: &Self::Instruction) -> Vec; +``` + +Benefits: +- Client doesn't need to know which accounts need proofs +- SDK is sync (no RPC) +- Fast exit when `inputs.is_empty()` +- Reuses existing types +- Follows existing trait patterns diff --git a/sdk-libs/client/docs/init-flow-visual-analysis.md b/sdk-libs/client/docs/init-flow-visual-analysis.md new file mode 100644 index 0000000000..566a109456 --- /dev/null +++ b/sdk-libs/client/docs/init-flow-visual-analysis.md @@ -0,0 +1,395 @@ +# Init Flow: Visual Analysis & Gap Fill + +## Side-by-Side Flow Comparison + +### Current Test Code Flow + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ test_amm_full_lifecycle (current) │ +├────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ derive_amm_pdas() // 50 lines of manual PDA derivation │ │ +│ │ - pool_state, pool_state_bump │ │ +│ │ - observation_state, observation_state_bump │ │ +│ │ - authority, authority_bump │ │ +│ │ - token_0_vault, token_0_vault_bump │ │ +│ │ - ... etc │ │ +│ └───────────────────────────────┬─────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ get_create_accounts_proof() │ │ +│ │ vec![ │ │ +│ │ CreateAccountsProofInput::pda(pdas.pool_state), // ❌ │ │ +│ │ CreateAccountsProofInput::pda(pdas.observation_state), │ │ +│ │ CreateAccountsProofInput::mint(pdas.lp_mint_signer), // ❌ │ │ +│ │ ] │ │ +│ │ │ │ +│ │ ❌ Client must know: which accounts need proofs │ │ +│ │ ❌ Client must know: pda() vs mint() distinction │ │ +│ │ ❌ Hidden: address tree fetch + address derivation │ │ +│ └───────────────────────────────┬─────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Build instruction manually │ │ +│ │ - Repeat all pubkeys from pdas │ │ +│ │ - Use bumps from pdas │ │ +│ │ - Attach remaining_accounts from proof │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Problems: │ +│ 1. PDA derivation duplicated (could be in SDK) │ +│ 2. Proof input selection requires protocol knowledge │ +│ 3. No type safety for instruction building │ +│ 4. Address derivation hidden inside get_create_accounts_proof │ +│ │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +### Ideal Design Flow + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ test_amm_full_lifecycle (ideal) │ +├────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ // Get address tree once (cacheable) │ │ +│ │ let address_tree = rpc.get_address_tree_v2().tree; │ │ +│ │ │ │ +│ │ ✅ Explicit dependency │ │ +│ │ ✅ Cacheable by aggregators │ │ +│ └───────────────────────────────┬─────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ AmmSdk::derive_init_pool(&config, &m0, &m1, &creator, &tree) │ │ +│ │ │ │ +│ │ Returns InitPoolDerived { │ │ +│ │ accounts: InitPoolAccounts { │ │ +│ │ pool_state, pool_state_bump, // ✅ All in one │ │ +│ │ observation_state, observation_state_bump, │ │ +│ │ lp_mint_signer, lp_mint_signer_bump, │ │ +│ │ lp_mint, // ✅ Derived too │ │ +│ │ token_0_vault, token_0_vault_bump, │ │ +│ │ token_1_vault, token_1_vault_bump, │ │ +│ │ creator_lp_token, creator_lp_token_bump, │ │ +│ │ authority, authority_bump, │ │ +│ │ }, │ │ +│ │ proof_inputs: [ // ✅ Pre-selected │ │ +│ │ { address, tree, name: "pool_state" }, │ │ +│ │ { address, tree, name: "observation_state" }, │ │ +│ │ { address, tree: MINT_TREE, name: "lp_mint" }, │ │ +│ │ ], │ │ +│ │ } │ │ +│ │ │ │ +│ │ ✅ SYNC - no RPC │ │ +│ │ ✅ SDK knows which accounts need proofs │ │ +│ │ ✅ Typed struct with bumps │ │ +│ │ ✅ Debug names included │ │ +│ └───────────────────────────────┬─────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ get_create_accounts_proof_from_addresses( │ │ +│ │ &rpc, &program_id, derived.addresses_with_trees() │ │ +│ │ ) │ │ +│ │ │ │ +│ │ ✅ Uses pre-computed addresses │ │ +│ │ ✅ No address derivation hidden │ │ +│ │ ✅ Batchable for aggregators │ │ +│ └───────────────────────────────┬─────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ // Type-safe instruction building │ │ +│ │ let accounts = derived.accounts.to_anchor_accounts(...); │ │ +│ │ let params = derived.accounts.to_params(proof, 1000, 1000); │ │ +│ │ │ │ +│ │ ✅ No manual pubkey wiring │ │ +│ │ ✅ Bumps auto-populated │ │ +│ │ ✅ Compile-time type checking │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Gap Analysis: What Each Design Misses + +### Current (v1 spec) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ GAP: Client must know proof selection rules │ +│ │ +│ "Only PDAs and Mints need proofs, not token accounts" │ +│ │ +│ ❌ Not documented clearly │ +│ ❌ Easy to get wrong │ +│ ❌ Protocol detail leaked to client │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ GAP: PDA derivation duplicated │ +│ │ +│ Client: derive_amm_pdas() // 50 lines │ +│ SDK: (internal derivation) // Another 50 lines │ +│ │ +│ ❌ DRY violation │ +│ ❌ Risk of divergence │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ GAP: No type safety for instruction building │ +│ │ +│ InitializePool { │ +│ pool_state: pdas.pool_state, // manual │ +│ observation_state: pdas.observation_state, │ +│ ... // error-prone │ +│ } │ +│ │ +│ ❌ Can miss accounts │ +│ ❌ Can use wrong pubkey │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Design A (Manifest) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ GAP: No bumps │ +│ │ +│ ManifestEntry { pubkey, role, name } │ +│ │ +│ ❌ Can't build instruction params │ +│ ❌ Must re-derive for bumps │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ GAP: Generic manifest, not type-safe │ +│ │ +│ manifest.get("pool_state") // returns Option │ +│ │ +│ ❌ String-based lookup │ +│ ❌ No compile-time checking │ +│ ❌ Typos fail at runtime │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ GAP: to_proof_inputs() hides address derivation │ +│ │ +│ let inputs = manifest.to_proof_inputs(); │ +│ │ +│ ❌ Still some magic │ +│ ❌ Can't batch before derivation │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Design B (Raw Inputs) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ GAP: No debug names │ +│ │ +│ RawAddressInputs { │ +│ new_addresses: [AddressWithTree, ...] │ +│ } │ +│ │ +│ ❌ Hard to debug which address failed │ +│ ❌ No audit trail │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ GAP: High verbosity for simple cases │ +│ │ +│ let tree = rpc.get_address_tree_v2().tree; │ +│ let derived = AmmSdk::derive_init_pool(..., &tree); │ +│ let proof = rpc.get_validity_proof(...).await?; │ +│ let packed = pack_proof(...)?; │ +│ // ... more steps │ +│ │ +│ ❌ 6+ steps for simple init │ +│ ❌ Overwhelming for beginners │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ GAP: No convenience layer │ +│ │ +│ ❌ Every client must implement full flow │ +│ ❌ No "just works" option │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Ideal Design: How It Fills All Gaps + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ FILLED: SDK knows proof selection │ +│ │ +│ InitPoolDerived.proof_inputs │ +│ - Pre-populated by SDK │ +│ - Client never selects │ +│ ✅ Protocol knowledge encapsulated │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ FILLED: Single source of truth for PDAs │ +│ │ +│ let derived = AmmSdk::derive_init_pool(...); │ +│ - All PDAs in derived.accounts │ +│ - All bumps included │ +│ - Client uses directly │ +│ ✅ No duplication │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ FILLED: Type-safe instruction building │ +│ │ +│ derived.accounts.to_anchor_accounts(...) │ +│ - Compiler checks all fields │ +│ - Bumps auto-populated │ +│ ✅ Compile-time safety │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ FILLED: Debug names for addresses │ +│ │ +│ AddressProofInput { address, tree, name: "pool_state" } │ +│ - derived.log_proof_inputs() │ +│ ✅ Debuggable, auditable │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ FILLED: Convenience layer for simple cases │ +│ │ +│ let (derived, proof) = derive_and_prove_init_pool(&rpc, ...).await?;│ +│ - One call for simple use │ +│ - Power users use raw derive │ +│ ✅ Both audiences served │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Aggregator-Specific Flows + +### Jupiter Integration + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ Jupiter: Multi-AMM Routing │ +├────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ // Jupiter aggregates multiple AMMs │ +│ let amms: Vec> = fetch_amms(&route).await?; │ +│ │ +│ // For Light Protocol AMMs, use our SDK │ +│ let tree = jupiter_cache.address_tree(); // Cached │ +│ │ +│ for amm in amms { │ +│ if let Some(light_amm) = amm.as_any().downcast_ref::() { │ +│ // Use ideal design pattern │ +│ let derived = light_amm.derive_accounts(&tree); │ +│ │ +│ // Log for audit │ +│ jupiter_logger.log_proof_inputs(&derived.proof_inputs); │ +│ │ +│ // Batch addresses │ +│ all_addresses.extend(derived.addresses_with_trees()); │ +│ } │ +│ } │ +│ │ +│ // Batch proof fetch (Jupiter's own prover) │ +│ let proofs = jupiter_prover.batch(all_addresses).await?; │ +│ │ +│ // Build instructions using typed accounts │ +│ for (derived, proof) in deriveds.iter().zip(proofs) { │ +│ let ix = build_ix(&derived.accounts, proof); │ +│ route_ixs.push(ix); │ +│ } │ +│ │ +└────────────────────────────────────────────────────────────────────────────┘ + +✅ Full control over proof batching +✅ Cacheable address tree +✅ Audit logging with names +✅ Type-safe instruction building +``` + +### DFlow Integration + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ DFlow: Order Flow with Custom Proof Infra │ +├────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ // DFlow processes orders through their system │ +│ async fn process_order(order: &Order) -> Result { │ +│ let tree = self.address_tree_cache.get(); │ +│ │ +│ // Derive accounts (SYNC - fast) │ +│ let derived = AmmSdk::derive_init_pool( │ +│ &order.config, &order.mint_0, &order.mint_1, │ +│ &order.creator, &tree │ +│ ); │ +│ │ +│ // DFlow has their own proof infrastructure │ +│ let addresses = derived.addresses_with_trees(); │ +│ │ +│ // Custom proof generation (maybe GPU-accelerated) │ +│ let proof = self.dflow_prover.generate(addresses).await?; │ +│ │ +│ // Type-safe instruction │ +│ Ok(derived.accounts.to_instruction(proof)) │ +│ } │ +│ │ +│ // Batch processing │ +│ async fn process_batch(orders: &[Order]) -> Result> { │ +│ // Derive all (SYNC - parallelizable) │ +│ let deriveds: Vec<_> = orders │ +│ .par_iter() │ +│ .map(|o| AmmSdk::derive_init_pool(...)) │ +│ .collect(); │ +│ │ +│ // Batch all addresses │ +│ let all_addresses: Vec<_> = deriveds │ +│ .iter() │ +│ .flat_map(|d| d.addresses_with_trees()) │ +│ .collect(); │ +│ │ +│ // Single proof batch (efficient) │ +│ let proofs = self.prover.batch(all_addresses).await?; │ +│ │ +│ // Build all instructions │ +│ Ok(deriveds.iter().zip(proofs).map(|(d, p)| d.to_ix(p)).collect()) │ +│ } │ +│ │ +└────────────────────────────────────────────────────────────────────────────┘ + +✅ SYNC derivation for parallelization +✅ Custom proof infrastructure +✅ Batch-friendly address extraction +✅ No forced RPC patterns +``` + +--- + +## Summary: Design Decisions + +| Decision | Rationale | +|----------|-----------| +| Typed PDA struct | Type safety > flexibility | +| Bumps included | Required for instruction params | +| address_tree param | Explicit > hidden RPC | +| AddressProofInput with name | Debugging is non-negotiable | +| addresses_with_trees() | Raw protocol types for batching | +| to_anchor_accounts() | Type-safe instruction building | +| Optional convenience wrapper | Support both simple and advanced users | +| SYNC derive, ASYNC proof | Clear separation of concerns | From b53c6974154c1bd2baf195db86e43af914a9c94c Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Mon, 26 Jan 2026 02:30:59 +0000 Subject: [PATCH 2/6] add helper to ammsdk for create_initialize_pool_proof_inputs --- .../src/interface/create_accounts_proof.rs | 30 ++++++++++++------- .../src/lib.rs | 16 +++++++++- .../tests/amm_test.rs | 21 ++++++------- .../tests/basic_test.rs | 12 ++++---- .../tests/mint/metadata_test.rs | 2 +- sdk-tests/single-mint-test/tests/test.rs | 2 +- 6 files changed, 51 insertions(+), 32 deletions(-) diff --git a/sdk-libs/client/src/interface/create_accounts_proof.rs b/sdk-libs/client/src/interface/create_accounts_proof.rs index 9b4cff501d..83ee1b47fd 100644 --- a/sdk-libs/client/src/interface/create_accounts_proof.rs +++ b/sdk-libs/client/src/interface/create_accounts_proof.rs @@ -2,9 +2,9 @@ //! Programs must pass this to light accounts that they initialize. use light_compressed_account::instruction_data::compressed_proof::ValidityProof; -use light_compressed_token_sdk::compressed_token::create_compressed_mint::derive_mint_compressed_address; +use light_compressed_token_sdk::compressed_token::create_compressed_mint::find_mint_address; use light_sdk::instruction::PackedAddressTreeInfo; -use light_token_interface::MINT_ADDRESS_TREE; +use light_token_interface::{LIGHT_TOKEN_PROGRAM_ID, MINT_ADDRESS_TREE}; use solana_instruction::AccountMeta; use solana_pubkey::Pubkey; use thiserror::Error; @@ -39,7 +39,7 @@ pub enum CreateAccountsProofInput { Pda(Pubkey), /// PDA with explicit owner (for cross-program accounts) PdaWithOwner { pda: Pubkey, owner: Pubkey }, - /// Mint (always uses LIGHT_TOKEN_PROGRAM_ID internally) + /// Mint account (the on-chain mint address) Mint(Pubkey), } @@ -56,10 +56,17 @@ impl CreateAccountsProofInput { Self::PdaWithOwner { pda, owner } } - /// Compressed mint (Mint). - /// Address derived: `derive_mint_compressed_address(&mint_signer, &tree)` - pub fn mint(mint_signer: Pubkey) -> Self { - Self::Mint(mint_signer) + /// Compressed mint using the on-chain mint address. + /// Pass the actual mint PDA (from `find_mint_address(signer)`), not the signer. + pub fn mint(mint: Pubkey) -> Self { + Self::Mint(mint) + } + + /// Compressed mint using the signer seed. Derives mint address internally. + /// Use when you only have the mint signer PDA, not the derived mint address. + pub fn mint_from_signer(signer: Pubkey) -> Self { + let (mint, _) = find_mint_address(&signer); + Self::Mint(mint) } /// Derive the cold address (mints always use MINT_ADDRESS_TREE). @@ -75,10 +82,11 @@ impl CreateAccountsProofInput { &address_tree.to_bytes(), &owner.to_bytes(), ), - // Mints always use MINT_ADDRESS_TREE regardless of passed tree - Self::Mint(signer) => { - derive_mint_compressed_address(signer, &Pubkey::new_from_array(MINT_ADDRESS_TREE)) - } + Self::Mint(mint) => light_compressed_account::address::derive_address( + &mint.to_bytes(), + &MINT_ADDRESS_TREE, + &LIGHT_TOKEN_PROGRAM_ID, + ), } } diff --git a/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs b/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs index 6966efb783..dcb727ed56 100644 --- a/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs +++ b/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs @@ -14,7 +14,7 @@ use csdk_anchor_full_derived_test::{ }; use light_client::interface::{ matches_discriminator, AccountInterface, AccountSpec, AccountToFetch, ColdContext, - LightProgramInterface, PdaSpec, + CreateAccountsProofInput, LightProgramInterface, PdaSpec, }; use light_sdk::LightDiscriminator; use solana_pubkey::Pubkey; @@ -440,4 +440,18 @@ impl AmmSdk { .ok_or(AmmSdkError::MissingField("token_1_mint"))?, }) } + + /// Creates proof inputs for InitializePool instruction. + /// Pass on-chain addresses (pool_state PDA, observation_state PDA, lp_mint). + pub fn create_initialize_pool_proof_inputs( + pool_state: Pubkey, + observation_state: Pubkey, + lp_mint: Pubkey, + ) -> Vec { + vec![ + CreateAccountsProofInput::pda(pool_state), + CreateAccountsProofInput::pda(observation_state), + CreateAccountsProofInput::mint(lp_mint), + ] + } } diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs index 1ae4913fb1..db9f617a8b 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs @@ -18,7 +18,7 @@ use csdk_anchor_full_derived_test::amm_test::{ use csdk_anchor_full_derived_test_sdk::{AmmInstruction, AmmSdk}; use light_client::interface::{ create_load_instructions, get_create_accounts_proof, AccountInterfaceExt, - CreateAccountsProofInput, InitializeRentFreeConfig, LightProgramInterface, + InitializeRentFreeConfig, LightProgramInterface, }; use light_compressible::rent::SLOTS_PER_EPOCH; use light_macros::pubkey; @@ -299,17 +299,14 @@ async fn test_amm_full_lifecycle() { &ctx.creator.pubkey(), ); - let proof_result = get_create_accounts_proof( - &ctx.rpc, - &ctx.program_id, - vec![ - CreateAccountsProofInput::pda(pdas.pool_state), - CreateAccountsProofInput::pda(pdas.observation_state), - CreateAccountsProofInput::mint(pdas.lp_mint_signer), - ], - ) - .await - .unwrap(); + let proof_inputs = AmmSdk::create_initialize_pool_proof_inputs( + pdas.pool_state, + pdas.observation_state, + pdas.lp_mint, + ); + let proof_result = get_create_accounts_proof(&ctx.rpc, &ctx.program_id, proof_inputs) + .await + .unwrap(); let init_amount_0 = 1000u64; let init_amount_1 = 1000u64; diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs index 8fa22eb893..9da12d4a3a 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/basic_test.rs @@ -143,7 +143,7 @@ async fn test_create_pdas_and_mint_auto() { vec![ CreateAccountsProofInput::pda(user_record_pda), CreateAccountsProofInput::pda(game_session_pda), - CreateAccountsProofInput::mint(mint_signer_pda), + CreateAccountsProofInput::mint(mint_pda), ], ) .await @@ -571,8 +571,8 @@ async fn test_create_two_mints() { &rpc, &program_id, vec![ - CreateAccountsProofInput::mint(mint_signer_a_pda), - CreateAccountsProofInput::mint(mint_signer_b_pda), + CreateAccountsProofInput::mint(cmint_a_pda), + CreateAccountsProofInput::mint(cmint_b_pda), ], ) .await @@ -772,9 +772,9 @@ async fn test_create_multi_mints() { &rpc, &program_id, vec![ - CreateAccountsProofInput::mint(mint_signer_a_pda), - CreateAccountsProofInput::mint(mint_signer_b_pda), - CreateAccountsProofInput::mint(mint_signer_c_pda), + CreateAccountsProofInput::mint(cmint_a_pda), + CreateAccountsProofInput::mint(cmint_b_pda), + CreateAccountsProofInput::mint(cmint_c_pda), ], ) .await diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/mint/metadata_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/mint/metadata_test.rs index c614beb673..9c3eed953e 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/mint/metadata_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/mint/metadata_test.rs @@ -69,7 +69,7 @@ async fn test_create_mint_with_metadata() { let proof_result = get_create_accounts_proof( &rpc, &program_id, - vec![CreateAccountsProofInput::mint(mint_signer_pda)], + vec![CreateAccountsProofInput::mint(cmint_pda)], ) .await .unwrap(); diff --git a/sdk-tests/single-mint-test/tests/test.rs b/sdk-tests/single-mint-test/tests/test.rs index fb289a6e15..c237cfd5d8 100644 --- a/sdk-tests/single-mint-test/tests/test.rs +++ b/sdk-tests/single-mint-test/tests/test.rs @@ -58,7 +58,7 @@ async fn test_create_single_mint() { let proof_result = get_create_accounts_proof( &rpc, &program_id, - vec![CreateAccountsProofInput::mint(mint_signer_pda)], + vec![CreateAccountsProofInput::mint(mint_pda)], ) .await .unwrap(); From f5b4da5f4822d3c376a28fb5ab238447e661cf8f Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Mon, 26 Jan 2026 19:23:19 +0000 Subject: [PATCH 3/6] mds --- sdk-libs/client/docs/client-trait.md | 408 +++++++++ sdk-libs/client/docs/fulldesign.md | 1170 ++++++++++++++++++++++++++ 2 files changed, 1578 insertions(+) create mode 100644 sdk-libs/client/docs/client-trait.md create mode 100644 sdk-libs/client/docs/fulldesign.md diff --git a/sdk-libs/client/docs/client-trait.md b/sdk-libs/client/docs/client-trait.md new file mode 100644 index 0000000000..dccba891ef --- /dev/null +++ b/sdk-libs/client/docs/client-trait.md @@ -0,0 +1,408 @@ +# LightProgramClient Trait: General Client for Compressible Programs + +## Overview + +The `CompressibleAmm` trait is AMM/swap-specific (for Jupiter integration). This document defines `LightProgramClient` - a general trait for ANY program with compressible accounts, supporting ANY instruction type. + +--- + +## Problem Statement + +A program might have many instructions, each using different accounts: + +``` +Program: AMM +├── swap() → [pool, vault_0, vault_1, user_ata_0, user_ata_1] +├── add_liquidity() → [pool, vault_0, vault_1, user_ata_0, user_ata_1, lp_mint] +├── remove_liquidity() → [pool, vault_0, vault_1, user_ata_0, user_ata_1, lp_mint] +├── claim_fees() → [pool, fee_vault, admin] +└── update_config() → [pool, admin] +``` + +Each instruction needs different accounts. A client executing `add_liquidity` shouldn't need to load accounts only used by `claim_fees`. + +--- + +## Design Goals + +1. **Per-instruction granularity** - load only accounts needed for the specific instruction +2. **Type-safe instruction kinds** - SDK defines enum of instruction types +3. **Consistent with CompressibleAmm** - similar patterns, can share implementation +4. **Minimal client code** - easy to use correctly + +--- + +## Trait Definition + +```rust +/// General client trait for programs with compressible accounts. +/// Supports any instruction type, not just swaps. +pub trait LightProgramClient { + /// The typed variant enum (generated by #[light_program] macro). + type Variant: Pack + Clone + Debug; + + /// Enum of instruction kinds this program supports. + /// SDK defines this based on program's instruction set. + type InstructionKind: Copy + Debug; + + /// Program ID + fn program_id(&self) -> Pubkey; + + // === Account Discovery === + + /// All compressible accounts across all instructions. + /// Used for initial fetching/caching. + fn get_compressible_accounts(&self) -> Vec; + + /// Accounts needed for a specific instruction kind. + fn get_accounts_for_instruction(&self, kind: Self::InstructionKind) -> Vec; + + /// Compressible accounts for a specific instruction (subset of above). + fn get_compressible_accounts_for_instruction(&self, kind: Self::InstructionKind) -> Vec; + + // === State Management === + + /// Update from AccountInterface (Photon response). + /// Stores ColdAccountSpec for cold accounts. + fn update_with_interfaces(&mut self, accounts: &[AccountInterface]) -> Result<()>; + + // === Cold Account Queries === + + /// Get cold specs for accounts needed by an instruction. + fn get_cold_specs_for_instruction(&self, kind: Self::InstructionKind) -> Vec>; + + /// Check if any accounts for instruction are cold. + fn has_cold_accounts_for_instruction(&self, kind: Self::InstructionKind) -> bool; +} +``` + +--- + +## InstructionKind Enum Example + +```rust +/// Generated or hand-written for each program +#[derive(Copy, Clone, Debug)] +pub enum AmmInstructionKind { + Swap, + AddLiquidity, + RemoveLiquidity, + ClaimFees, + UpdateConfig, +} + +impl LightProgramClient for AmmSdk { + type Variant = LightAccountVariant; + type InstructionKind = AmmInstructionKind; + + fn get_accounts_for_instruction(&self, kind: Self::InstructionKind) -> Vec { + match kind { + AmmInstructionKind::Swap => vec![ + self.pool_state, + self.vault_0, + self.vault_1, + ], + AmmInstructionKind::AddLiquidity => vec![ + self.pool_state, + self.vault_0, + self.vault_1, + self.lp_mint, + ], + AmmInstructionKind::ClaimFees => vec![ + self.pool_state, + self.fee_vault, + ], + // ... + } + } + + fn get_compressible_accounts_for_instruction(&self, kind: Self::InstructionKind) -> Vec { + // Filter to only compressible ones + self.get_accounts_for_instruction(kind) + .into_iter() + .filter(|pk| self.is_compressible(pk)) + .collect() + } + + // ... +} +``` + +--- + +## Client Usage Flow + +```rust +// 1. Client wants to add liquidity +let kind = AmmInstructionKind::AddLiquidity; + +// 2. Check which accounts are needed and might be cold +let compressible = sdk.get_compressible_accounts_for_instruction(kind); + +// 3. Fetch from Photon (only if not already cached) +let interfaces = photon.get_account_interfaces(&compressible).await?; +sdk.update_with_interfaces(&interfaces)?; + +// 4. Check if any are cold +if sdk.has_cold_accounts_for_instruction(kind) { + // 5. Get cold specs for THIS instruction only + let cold_specs = sdk.get_cold_specs_for_instruction(kind); + + // 6. Build load instructions + let load_ixs = create_load_instructions(&cold_specs, payer, config).await?; + instructions.extend(load_ixs); +} + +// 7. Build the actual instruction (SDK-specific method) +let add_liq_ix = sdk.build_add_liquidity(amount_0, amount_1, payer)?; +instructions.push(add_liq_ix); + +// 8. Execute transaction +``` + +--- + +## Visual: Per-Instruction Cold Detection + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ PER-INSTRUCTION COLD DETECTION │ +└─────────────────────────────────────────────────────────────────────────────────┘ + + SDK stores all cold specs: + + cold_specs: HashMap> + ├── pool_state → ColdAccountSpec::Pda { ... } + ├── vault_0 → ColdAccountSpec::Token { ... } + └── fee_vault → ColdAccountSpec::Token { ... } + + + Client calls: get_cold_specs_for_instruction(Swap) + │ + ▼ + ┌───────────────────────────────┐ + │ Swap needs: pool, vault_0, │ + │ vault_1 │ + └───────────────────────────────┘ + │ + ▼ + ┌───────────────────────────────┐ + │ Filter cold_specs to these │ + │ pubkeys only │ + └───────────────────────────────┘ + │ + ▼ + Returns: [pool_state spec, vault_0 spec] + (vault_1 is hot, not included) + (fee_vault not needed for Swap, not included) +``` + +--- + +## Relationship with CompressibleAmm + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ TRAIT HIERARCHY │ +└─────────────────────────────────────────────────────────────────────────────────┘ + + ┌─────────────────────────────┐ + │ LightProgramClient │ + │ (general, any program) │ + │ │ + │ - InstructionKind enum │ + │ - per-instruction methods │ + └─────────────────────────────┘ + │ + │ implements + ▼ + ┌─────────────────────────────┐ + │ AmmSdk │ + │ │ + │ LightProgramClient impl │ + │ + Amm impl │ + │ + CompressibleAmm impl │ + └─────────────────────────────┘ + + CompressibleAmm is a SPECIALIZATION for Jupiter/aggregators: + + - get_cold_specs() = get_cold_specs_for_instruction(Swap) + - has_cold_accounts() = has_cold_accounts_for_instruction(Swap) + - get_compressible_accounts() = get_compressible_accounts_for_instruction(Swap) + + The SDK can implement both traits, with CompressibleAmm delegating to + LightProgramClient methods for the Swap instruction kind. +``` + +--- + +## Implementation: CompressibleAmm as Wrapper + +`CompressibleAmm` is just `LightProgramClient` pinned to the `Swap` instruction: + +```rust +impl CompressibleAmm for AmmSdk { + type Variant = LightAccountVariant; + + fn get_compressible_accounts(&self) -> Vec { + // Swap-specific: only accounts needed for swap + LightProgramClient::get_compressible_accounts_for_instruction( + self, + AmmInstructionKind::Swap + ) + } + + fn get_cold_specs(&self) -> Vec> { + // Swap-specific: only cold specs for swap accounts + LightProgramClient::get_cold_specs_for_instruction( + self, + AmmInstructionKind::Swap + ) + } + + fn has_cold_accounts(&self) -> bool { + // Swap-specific: any swap accounts cold? + LightProgramClient::has_cold_accounts_for_instruction( + self, + AmmInstructionKind::Swap + ) + } + + fn update_with_interfaces(&mut self, accounts: &[AccountInterface]) -> Result<()> { + // Same - updates all accounts, storage is shared + LightProgramClient::update_with_interfaces(self, accounts) + } +} +``` + +**Key point:** Aggregators call `CompressibleAmm::get_cold_specs()` which returns only swap-relevant cold specs. They don't need or see `add_liquidity` accounts. + +--- + +## SDK Internal Storage (Same as Before) + +```rust +struct AmmSdk { + // Parsed scalars for quoting/operations + pool_key: Pubkey, + reserve_0: u64, + reserve_1: u64, + // ... + + // All cold specs (unified storage) + cold_specs: HashMap>, + + // Account pubkeys per type (for instruction routing) + pool_state: Pubkey, + vault_0: Pubkey, + vault_1: Pubkey, + lp_mint: Pubkey, + fee_vault: Pubkey, + // ... +} + +impl AmmSdk { + /// Helper: filter cold_specs to given pubkeys + fn filter_cold_specs(&self, pubkeys: &[Pubkey]) -> Vec> { + pubkeys.iter() + .filter_map(|pk| self.cold_specs.get(pk).cloned()) + .collect() + } +} +``` + +--- + +## Method Summary + +| Method | Purpose | +|--------|---------| +| `program_id()` | Program ID | +| `get_compressible_accounts()` | ALL compressible accounts (for initial fetch/caching) | +| `get_accounts_for_instruction(kind)` | Accounts needed for specific instruction | +| `get_compressible_accounts_for_instruction(kind)` | Compressible subset for instruction | +| `update_with_interfaces(&[AccountInterface])` | Update from Photon | +| `get_cold_specs_for_instruction(kind)` | Cold specs for instruction's accounts | +| `has_cold_accounts_for_instruction(kind)` | Check if instruction has cold accounts | + +**Note:** No `get_all_cold_specs()` needed. Each caller specifies the instruction kind they care about. + +--- + +## Comparison: CompressibleAmm vs LightProgramClient + +| Aspect | CompressibleAmm | LightProgramClient | +|--------|-----------------|-------------------| +| Use case | Jupiter/aggregator swaps | Any instruction | +| Instruction scope | Swap only | Per-instruction | +| Granularity | All swap accounts | Specific instruction accounts | +| Inheritance | Extends Amm trait | Standalone | +| Who uses | Aggregators | General clients, wallets, dApps | + +--- + +## Benefits of Per-Instruction Design + +1. **Efficiency** - Only load accounts needed for the specific operation +2. **Clarity** - SDK explicitly knows which accounts each instruction needs +3. **Flexibility** - Support any instruction, not just swap +4. **Composability** - CompressibleAmm can delegate to LightProgramClient + +--- + +## Open Questions + +### 1. Should InstructionKind be generic or use concrete enum? + +**Option A: Associated type (current)** +```rust +type InstructionKind: Copy + Debug; +``` +Each SDK defines its own enum. + +**Option B: Generic parameter** +```rust +trait LightProgramClient { ... } +``` + +Recommendation: Associated type is simpler. + +### 2. Should we auto-generate InstructionKind from program? + +The `#[light_program]` macro could generate the enum: +```rust +#[light_program] +mod amm { + pub fn swap(...) { ... } + pub fn add_liquidity(...) { ... } +} + +// Generated: +pub enum AmmInstructionKind { Swap, AddLiquidity } +``` + +This would be ideal for consistency. + +### 3. How does this interact with instruction building? + +The trait handles compression (which accounts are cold). Instruction building is SDK-specific: +```rust +// SDK-specific methods (not in trait) +impl AmmSdk { + fn build_swap(&self, ...) -> Instruction { ... } + fn build_add_liquidity(&self, ...) -> Instruction { ... } +} +``` + +The trait doesn't dictate instruction building - that's program-specific. + +--- + +## Summary + +`LightProgramClient` is the general trait for any compressible program: +- Per-instruction account discovery +- Per-instruction cold detection +- `CompressibleAmm` is a specialization for swaps that can delegate to it + +This enables clients to efficiently load only the accounts needed for their specific operation. diff --git a/sdk-libs/client/docs/fulldesign.md b/sdk-libs/client/docs/fulldesign.md new file mode 100644 index 0000000000..d11a6b701a --- /dev/null +++ b/sdk-libs/client/docs/fulldesign.md @@ -0,0 +1,1170 @@ +# Compression-Aware AMM Integration: Full Design + +## Executive Summary + +This document specifies how DEX aggregators (Jupiter, DFlow) integrate with compression-enabled AMMs. The design is **non-breaking** - existing AMMs work unchanged, compressible AMMs opt-in via extension trait. + +--- + +## Part 1: Trait Design + +### 1.1 Jupiter's Amm Trait (Unchanged) + +```rust +pub trait Amm { + fn from_keyed_account(keyed_account: &KeyedAccount, ctx: &AmmContext) -> Result; + fn label(&self) -> String; + fn program_id(&self) -> Pubkey; + fn key(&self) -> Pubkey; + fn get_reserve_mints(&self) -> Vec; + fn get_accounts_to_update(&self) -> Vec; // UNCHANGED + fn update(&mut self, account_map: &AccountMap) -> Result<()>; + fn quote(&self, params: &QuoteParams) -> Result; + fn get_swap_and_account_metas(&self, params: &SwapParams) -> Result; + fn clone_amm(&self) -> Box; +} +``` + +### 1.2 CompressibleAmm Extension Trait (New) + +```rust +/// Extension trait for AMMs supporting rent-free (compressible) accounts. +/// Implemented IN ADDITION TO the standard Amm trait. +pub trait CompressibleAmm: Amm { + /// The typed variant enum generated by #[light_program] macro. + /// Contains parsed account data + seed values for PDA verification. + type Variant: Pack + Clone + Debug; + + /// Returns the SUBSET of accounts that could be cold. + /// This is a subset of get_accounts_to_update(). + /// Aggregator uses this to decide which closures need Photon queries. + fn get_compressible_accounts(&self) -> Vec; + + /// Update from AccountInterface slice (works uniformly for hot/cold). + /// For cold accounts, converts to ColdAccountSpec and stores internally. + fn update_with_interfaces(&mut self, accounts: &[AccountInterface]) -> Result<()>; + + /// Get lean specs for cold accounts only (for load instruction building). + /// Returns ColdAccountSpec which has NO redundant Account data. + fn get_cold_specs(&self) -> Vec>; + + /// Check if any currently cached accounts are cold. + fn has_cold_accounts(&self) -> bool; +} +``` + +**Key distinction:** + +``` +get_accounts_to_update() → ALL accounts AMM needs (from Amm trait) +get_compressible_accounts() → SUBSET that could be cold (from CompressibleAmm) + +Example for a pool: + get_accounts_to_update(): [pool_state, vault_0, vault_1, observation, + lp_mint, amm_config, token_program] + + get_compressible_accounts(): [pool_state, vault_0, vault_1, observation, lp_mint] + ↑ only these can go cold +``` + +### 1.3 Core Types + +```rust +/// Lean cold account spec - NO redundancy. +/// Does NOT store Account struct (which would duplicate data from compressed). +pub enum ColdAccountSpec { + /// Program-owned PDA - needs Variant for pack() + Pda { + key: Pubkey, + compressed: CompressedAccount, // hash, tree_info, data + variant: V, // parsed data + seeds + program_id: Pubkey, + }, + /// Program-owned token account (vault) + Token { + key: Pubkey, + compressed: CompressedTokenAccount, // has .token with mint, owner, amount + }, + /// Compressed mint + Mint { + key: Pubkey, + compressed: CompressedAccount, // parse mint from .data on demand + }, +} + +/// What's in CompressedAccount (from indexer): +/// - hash: [u8; 32] - for proof fetching +/// - tree_info: TreeInfo - Merkle tree pubkey, queue +/// - leaf_index: u32 - position in tree +/// - data: CompressedData - discriminator + raw bytes +/// - lamports: u64 - for account recreation +/// - owner: Pubkey - for account recreation + +/// What's in CompressedTokenAccount: +/// - account: CompressedAccount - underlying compressed account +/// - token: TokenData - mint, owner, amount, delegate, state + +/// Unified account interface - for FETCHING (hot or cold). +/// Used by Photon API, passed to update_with_interfaces(). +pub struct AccountInterface { + pub key: Pubkey, + pub account: Account, // Synthetic for cold (for .data() uniformity) + pub cold: Option, // Present when cold +} + +/// Cold context - bridges AccountInterface to ColdAccountSpec. +pub enum ColdContext { + Account(CompressedAccount), // For PDAs and mints + Token(CompressedTokenAccount), // For token accounts +} +``` + +**Why ColdAccountSpec is lean:** + +| Current AccountSpec | Lean ColdAccountSpec | +|---------------------|----------------------| +| Embeds `Account` struct | No `Account` struct | +| `Account.data` duplicates `CompressedAccount.data` | Single source of truth | +| ~200+ bytes overhead per cold account | Minimal overhead | + +**Field usage in load instructions:** + +| Field | Used For | +|-------|----------| +| `key` | Hot account address (recreation target) | +| `compressed.hash` | Proof fetching from indexer | +| `compressed.tree_info` | Merkle tree accounts in instruction | +| `variant` (PDA only) | `pack()` serializes data + seeds | +| `program_id` (PDA only) | CPI target for decompression | + +### 1.4 Converters: AccountInterface → ColdAccountSpec + +```rust +impl ColdAccountSpec { + /// Convert AccountInterface to ColdAccountSpec::Pda + /// Requires variant construction (SDK-specific). + pub fn from_pda_interface( + interface: &AccountInterface, + variant: V, + program_id: Pubkey, + ) -> Option { + let compressed = interface.as_compressed_account()?.clone(); + Some(Self::Pda { + key: interface.key, + compressed, + variant, + program_id, + }) + } + + /// Convert AccountInterface to ColdAccountSpec::Token + pub fn from_token_interface(interface: &AccountInterface) -> Option { + let compressed = interface.as_compressed_token()?.clone(); + Some(Self::Token { + key: interface.key, + compressed, + }) + } + + /// Convert AccountInterface to ColdAccountSpec::Mint + pub fn from_mint_interface(interface: &AccountInterface) -> Option { + let compressed = interface.as_compressed_account()?.clone(); + Some(Self::Mint { + key: interface.key, + compressed, + }) + } +} + +impl ColdAccountSpec { + pub fn key(&self) -> Pubkey { + match self { + Self::Pda { key, .. } => *key, + Self::Token { key, .. } => *key, + Self::Mint { key, .. } => *key, + } + } + + pub fn hash(&self) -> [u8; 32] { + match self { + Self::Pda { compressed, .. } => compressed.hash, + Self::Token { compressed, .. } => compressed.account.hash, + Self::Mint { compressed, .. } => compressed.hash, + } + } +} +``` + +### 1.5 What's NOT in the Trait + +```rust +// NOT NEEDED - Photon determines account type from pubkey +fn get_account_fetch_hints(&self) -> Vec; +``` + +### 1.6 Method Summary + +| Method | Source | Purpose | +|--------|--------|---------| +| `get_accounts_to_update()` | `Amm` trait | ALL accounts AMM needs (for streaming) | +| `get_compressible_accounts()` | `CompressibleAmm` | SUBSET that could be cold (for Photon queries) | +| `update()` | `Amm` trait | Update from hot accounts (stream) | +| `update_with_interfaces()` | `CompressibleAmm` | Update from Photon (hot or cold) | +| `get_cold_specs()` | `CompressibleAmm` | Get cold accounts for load instructions | +| `has_cold_accounts()` | `CompressibleAmm` | Check if any accounts are cold | + +--- + +## Part 2: SDK Internal Storage + +### 2.1 Regular AMM SDK (Baseline) + +```rust +struct RegularAmmSdk { + // Parsed scalars only - no raw Account storage + pool_key: Pubkey, + reserve_0: u64, + reserve_1: u64, + token_0_mint: Pubkey, + token_1_mint: Pubkey, + // ... other parsed state +} +``` + +### 2.1.1 Important: `update()` Must Clear Stale Cold Specs + +The regular `Amm::update()` method (from stream) must also handle decompression: + +```rust +impl Amm for CompressibleAmmSdk { + fn update(&mut self, account_map: &AccountMap) -> Result<()> { + for (pubkey, account) in account_map { + // Update scalars + self.parse_and_update_scalars(pubkey, &account.data)?; + + // IMPORTANT: Remove cold spec if account is now hot + // (this handles decompression - account came back on-chain) + self.cold_specs.remove(pubkey); + } + Ok(()) + } +} +``` + +This ensures that when an account is decompressed (appears on-chain again), the stale cold spec is removed. + +### 2.2 Compressible AMM SDK + +```rust +struct CompressibleAmmSdk { + // Same parsed scalars (for quoting) + pool_key: Pubkey, + reserve_0: u64, + reserve_1: u64, + token_0_mint: Pubkey, + token_1_mint: Pubkey, + + // ONLY for cold accounts - unified, lean storage + // ColdAccountSpec has NO redundant Account data + cold_specs: HashMap>, +} + +impl CompressibleAmm for CompressibleAmmSdk { + fn update_with_interfaces(&mut self, accounts: &[AccountInterface]) -> Result<()> { + for interface in accounts { + // Always update scalars (for quoting) + self.parse_and_update_scalars(&interface.key, interface.data())?; + + if interface.is_cold() { + // Convert to lean ColdAccountSpec and store + let spec = self.build_cold_spec(interface)?; + self.cold_specs.insert(interface.key, spec); + } else { + // Hot - remove any stale cold spec (decompression case) + self.cold_specs.remove(&interface.key); + } + } + Ok(()) + } + + fn get_cold_specs(&self) -> Vec> { + self.cold_specs.values().cloned().collect() + } + + fn has_cold_accounts(&self) -> bool { + !self.cold_specs.is_empty() + } +} +``` + +### 2.3 Storage Comparison + +| Account State | Regular SDK | Compressible SDK | +|---------------|-------------|------------------| +| Hot | scalars | scalars (same) | +| Cold | N/A | scalars + ColdAccountSpec | + +**Zero redundancy for hot accounts** - identical to regular SDK. + +**Minimal storage for cold accounts:** +- `ColdAccountSpec` - lean enum, NO Account struct +- Contains only: `CompressedAccount`/`CompressedTokenAccount` + Variant (for PDAs) +- Scalars still stored separately for fast quoting + +--- + +## Part 3: Aggregator Integration + +### 3.1 What Aggregator Must Adapt + +| Component | Change Required | +|-----------|-----------------| +| AMM registry | Add `is_compressible` flag per program | +| Account fetching | Add Photon client for cold queries | +| Update path | Branch: `update()` vs `update_with_interfaces()` | +| Swap building | Prepend load instructions when cold | +| Event detection | Detect compression events (see 3.4) | + +### 3.2 New Dependencies + +```rust +// Aggregator adds: +use light_client::{ + AccountInterface, + ColdSpec, + CompressibleAmm, + create_load_instructions, +}; +use photon_client::PhotonClient; // or equivalent indexer client +``` + +### 3.3 Aggregator Pseudocode + +```rust +// Initialization +let photon = PhotonClient::new(photon_url); +let compressible_programs: HashSet = load_registry(); + +// Main routing function +async fn route_swap(amm: &mut dyn Amm, params: SwapParams) -> Result { + let mut instructions = Vec::new(); + let pubkeys = amm.get_accounts_to_update(); + + if is_compressible(amm.program_id()) { + // COMPRESSIBLE PATH + let interfaces = photon.get_account_interfaces(&pubkeys).await?; + as_compressible(amm).update_with_interfaces(&interfaces)?; + + if as_compressible(amm).has_cold_accounts() { + let cold_specs = as_compressible(amm).get_cold_specs(); + let load_ixs = create_load_instructions(&cold_specs, payer, config).await?; + instructions.extend(load_ixs); + } + } else { + // REGULAR PATH (unchanged) + let accounts = rpc.get_multiple_accounts(&pubkeys).await?; + amm.update(&accounts)?; + } + + // Quote and swap (same for both) + let swap = amm.get_swap_and_account_metas(¶ms)?; + instructions.push(build_swap_ix(swap)); + + Ok(build_transaction(instructions)) +} +``` + +### 3.4 Detecting Cold Accounts + +**Question: How does aggregator know when an account goes cold?** + +#### Option A: Account Closure Detection (Recommended) + +``` +STREAM EVENT: account_update(pubkey, account) +│ +├─ account exists && lamports > 0 +│ └─ HOT - use directly via update(AccountMap) +│ +└─ account missing OR lamports == 0 + │ + ├─ is_compressible_program(program_id)? + │ │ + │ ├─ YES → is pubkey in get_compressible_accounts()? + │ │ │ + │ │ ├─ YES → query Photon + │ │ │ │ + │ │ │ ├─ Photon returns cold data → COMPRESSED + │ │ │ │ └─ call update_with_interfaces([interface]) + │ │ │ │ + │ │ │ └─ Photon returns nothing → ACTUALLY DELETED + │ │ │ └─ remove from cache + │ │ │ + │ │ └─ NO → non-compressible account closed (e.g., amm_config) + │ │ └─ handle as regular deletion + │ │ + │ └─ NO → regular AMM, account just closed + │ + └─ END +``` + +**Key optimization:** Don't query Photon for accounts that can't be cold. + +```rust +// Aggregator caches the compressible set per AMM +let compressible_set: HashSet = amm.get_compressible_accounts().into_iter().collect(); + +// On closure +if compressible_set.contains(&pubkey) { + // This account COULD be cold - query Photon + let interface = photon.get_account_interface(&pubkey).await?; + amm.update_with_interfaces(&[interface])?; +} else { + // Not compressible - regular deletion +} +``` + +**Why closure works:** +- Compression = account closed on-chain + data moved to Merkle tree +- Stream sees closure as `lamports = 0` or account disappearing +- Photon query distinguishes "compressed" from "actually deleted" + +#### Option B: Watch Compression Transactions + +```rust +// Subscribe to compress/decompress transactions +stream.subscribe_logs(|log| { + if log.contains("Compress") || log.contains("Decompress") { + let affected_accounts = parse_affected_accounts(log); + for pubkey in affected_accounts { + refresh_from_photon(pubkey); + } + } +}); +``` + +**Pros:** More explicit, catches compression before next quote request +**Cons:** Requires log parsing, more complex + +#### Option C: Periodic Photon Polling + +```rust +// Every N seconds, refresh compressible AMM accounts +for amm in compressible_amms { + let interfaces = photon.get_account_interfaces(amm.get_accounts_to_update()).await?; + amm.update_with_interfaces(&interfaces)?; +} +``` + +**Pros:** Simple, catches everything +**Cons:** Wasteful, adds latency + +#### Recommendation: Option A + Optional B + +- **Primary:** Detect via account closure in stream +- **Optional:** Also watch compress transactions for faster detection +- **Fallback:** Photon query always gives definitive answer + +--- + +## Part 4: Visual Diagrams + +### 4.1 High-Level Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ AGGREGATOR (Jupiter/DFlow) │ +└─────────────────────────────────────────────────────────────────────────────────┘ + │ │ + │ Regular AMMs │ Compressible AMMs + │ │ + ▼ ▼ +┌─────────────────────┐ ┌─────────────────────────────────────┐ +│ LaserStream/RPC │ │ LaserStream + Photon Fallback │ +│ │ │ │ +│ getMultipleAccounts│ │ Stream → if missing → Photon │ +│ → Account[] │ │ → AccountInterface[] │ +└─────────────────────┘ └─────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────┐ ┌─────────────────────────────────────┐ +│ amm.update( │ │ amm.update_with_interfaces( │ +│ AccountMap) │ │ &[AccountInterface]) │ +└─────────────────────┘ └─────────────────────────────────────┘ + │ │ + │ │ if has_cold_accounts() + │ ▼ + │ ┌─────────────────────────────────────┐ + │ │ create_load_instructions( │ + │ │ cold_specs, payer, config) │ + │ │ → Vec │ + │ └─────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ TRANSACTION │ +│ │ +│ Regular: [swap_ix] │ +│ Compressed: [load_ix_1, load_ix_2, ..., swap_ix] │ +└─────────────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 Account State Flow + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ ACCOUNT LIFECYCLE │ +└─────────────────────────────────────────────────────────────────────────────────┘ + + ┌──────────────┐ + │ CREATED │ + │ (on-chain) │ + └──────┬───────┘ + │ + ┌────────────────┼────────────────┐ + ▼ │ ▼ + ┌──────────────┐ │ ┌──────────────┐ + │ HOT │◄────────┴──────►│ COLD │ + │ (on-chain) │ compress/ │ (compressed) │ + │ │ decompress │ │ + └──────────────┘ └──────────────┘ + │ │ + │ │ + ▼ ▼ + ┌──────────────┐ ┌──────────────┐ + │ STREAM │ │ PHOTON │ + │ delivers │ │ delivers │ + │ Account │ │ Account- │ + │ │ │ Interface │ + └──────────────┘ └──────────────┘ + │ │ + └────────────┬───────────────────┘ + ▼ + ┌──────────────┐ + │ SDK parses │ + │ same way │ + │ (.data()) │ + └──────────────┘ +``` + +### 4.3 Cold Detection Flow + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ COLD ACCOUNT DETECTION │ +└─────────────────────────────────────────────────────────────────────────────────┘ + + STREAM: account_update(pool_vault_0, account) + │ + ▼ + ┌───────────────────────────────┐ + │ account.lamports > 0 ? │ + └───────────────────────────────┘ + │ │ + YES NO (closed/missing) + │ │ + ▼ ▼ + ┌──────────┐ ┌───────────────────────┐ + │ HOT │ │ is_compressible( │ + │ update │ │ program_id)? │ + │ cache │ └───────────────────────┘ + └──────────┘ │ │ + YES NO + │ │ + ▼ ▼ + ┌──────────────┐ ┌──────────┐ + │ Query Photon │ │ Regular │ + │ (pubkey) │ │ deletion │ + └──────────────┘ └──────────┘ + │ + ┌───────────────┴───────────────┐ + ▼ ▼ + ┌──────────────────┐ ┌──────────────────┐ + │ Photon returns │ │ Photon returns │ + │ AccountInterface │ │ nothing │ + │ with ColdContext │ │ │ + └──────────────────┘ └──────────────────┘ + │ │ + ▼ ▼ + ┌──────────────────┐ ┌──────────────────┐ + │ COMPRESSED │ │ ACTUALLY DELETED │ + │ Cache interface │ │ Remove from cache│ + │ SDK stores │ │ Pool may be │ + │ ColdSpec │ │ invalid │ + └──────────────────┘ └──────────────────┘ +``` + +### 4.4 SDK Internal Flow + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ SDK: update_with_interfaces() │ +└─────────────────────────────────────────────────────────────────────────────────┘ + + for interface in interfaces: + │ + ▼ + ┌───────────────────┐ + │ Parse account │ + │ data = interface │ + │ .data() │ + └───────────────────┘ + │ + ▼ + ┌───────────────────┐ + │ Update scalars │ reserve_0, reserve_1, etc. + │ (for quoting) │ SAME FOR HOT AND COLD + └───────────────────┘ + │ + ▼ + ┌───────────────────┐ + │ interface.is_ │ + │ cold()? │ + └───────────────────┘ + │ │ + YES NO + │ │ + ▼ ▼ + ┌──────────────┐ ┌──────────────┐ + │ Convert to │ │ Remove stale │ + │ lean spec: │ │ cold spec │ + │ │ │ (hot now) │ + │ ColdAccount- │ └──────────────┘ + │ Spec::from_ │ + │ *_interface()│ + │ │ + │ Store in │ + │ cold_specs │ + │ HashMap │ + └──────────────┘ +``` + +### 4.4.1 AccountInterface → ColdAccountSpec Conversion + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ TYPE CONVERSION FLOW │ +└─────────────────────────────────────────────────────────────────────────────────┘ + + AccountInterface (from Photon) + ├─ key: Pubkey + ├─ account: Account ← DISCARDED (redundant for cold) + └─ cold: Option ← EXTRACTED + │ + ▼ + ┌───────────────────────────────────────────────────────────────┐ + │ SDK determines account type (knows its own accounts) │ + └───────────────────────────────────────────────────────────────┘ + │ + ┌─────────┼─────────┬─────────────┐ + ▼ ▼ ▼ │ + ┌─────┐ ┌─────┐ ┌─────┐ │ + │ PDA │ │Token│ │Mint │ │ + └─────┘ └─────┘ └─────┘ │ + │ │ │ │ + ▼ ▼ ▼ │ + ┌─────────────────────────────────────────────────────────────┐ + │ ColdAccountSpec::Pda { │ + │ key: interface.key, │ + │ compressed: interface.cold.as_account().clone(), │ + │ variant: build_variant(interface.data()), ← SDK builds │ + │ program_id, │ + │ } │ + ├─────────────────────────────────────────────────────────────┤ + │ ColdAccountSpec::Token { │ + │ key: interface.key, │ + │ compressed: interface.cold.as_token().clone(), │ + │ } │ + ├─────────────────────────────────────────────────────────────┤ + │ ColdAccountSpec::Mint { │ + │ key: interface.key, │ + │ compressed: interface.cold.as_account().clone(), │ + │ } │ + └─────────────────────────────────────────────────────────────┘ + + Result: Lean storage, NO Account struct, NO redundant data +``` + +### 4.5 Load Instruction Building + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ create_load_instructions() │ +└─────────────────────────────────────────────────────────────────────────────────┘ + + Input: Vec> + │ + ▼ + ┌───────────────────────────────────────┐ + │ Collect hashes from each spec: │ + │ │ + │ Pda → compressed.hash │ + │ Token → compressed.account.hash │ + │ Mint → compressed.hash │ + └───────────────────────────────────────┘ + │ + ▼ + ┌───────────────────────────────────────┐ + │ Fetch validity proofs from indexer │ + │ │ + │ proofs = indexer.get_validity_ │ + │ proof(hashes) │ + └───────────────────────────────────────┘ + │ + ▼ + ┌───────────────────────────────────────┐ + │ Build instruction per spec type: │ + │ │ + │ Pda: │ + │ packed_data = variant.pack() │ + │ build_decompress_pda_ix(...) │ + │ │ + │ Token: │ + │ build_transfer2_ix( │ + │ compressed.token, ...) │ + │ │ + │ Mint: │ + │ mint_data = parse(compressed) │ + │ build_decompress_mint_ix(...) │ + └───────────────────────────────────────┘ + │ + ▼ + Output: Vec +``` + +--- + +## Part 5: The Diff + +### 5.1 Regular AMM vs Compressible AMM + +``` +┌──────────────────────────────────────────────────────────────────────────────────┐ +│ THE DIFF │ +└──────────────────────────────────────────────────────────────────────────────────┘ + +REGULAR AMM COMPRESSIBLE AMM +─────────── ───────────────── + +Trait: Trait: + impl Amm impl Amm (same) + impl CompressibleAmm (NEW) + +Fetching: Fetching: + RPC only RPC for hot + Photon for cold/missing (NEW) + +Update: Update: + update(AccountMap) update_with_interfaces( (NEW) + &[AccountInterface]) + +Storage: Storage: + Scalars only Scalars (same) + + ColdSpec for cold only (NEW) + +Quote: Quote: + From scalars From scalars (SAME) + +Swap: Swap: + [swap_ix] [load_ixs..., swap_ix] (NEW prefix) +``` + +### 5.2 Aggregator Changes Summary + +``` +┌──────────────────────────────────────────────────────────────────────────────────┐ +│ AGGREGATOR CHANGES │ +└──────────────────────────────────────────────────────────────────────────────────┘ + +BEFORE AFTER +────── ───── + +1. REGISTRY + program_id → AmmFactory program_id → AmmFactory + + is_compressible flag + +2. FETCHING + RPC.getMultipleAccounts if compressible && missing: + Photon.getAccountInterfaces + +3. UPDATE CALL + amm.update(account_map) if compressible: + amm.update_with_interfaces(interfaces) + else: + amm.update(account_map) + +4. SWAP BUILDING + [swap_ix] if has_cold_accounts(): + [load_ixs..., swap_ix] + else: + [swap_ix] + +5. EVENT HANDLING + (none) on account_closure: + if compressible: query Photon +``` + +--- + +## Part 6: Open Questions & Recommendations + +### 6.1 Is Account Closure the Best Detection Method? + +**Analysis:** + +| Method | Pros | Cons | +|--------|------|------| +| Account closure | Simple, uses existing stream | Ambiguous until Photon query | +| Compress tx logs | Explicit, immediate | Requires log parsing, more complex | +| Periodic polling | Simple | Wasteful, adds latency | +| Photon streaming | Ideal, real-time | May not exist yet | + +**Recommendation:** Account closure + immediate Photon query. + +**Critical insight:** Closure alone is ambiguous, but **Photon resolves it instantly**. + +``` +STREAM: account closed + │ + ├─ Non-compressible program → actually deleted (ignore) + │ + └─ Compressible program → IMMEDIATELY query Photon + │ + ├─ Photon has it → COMPRESSED (cache AccountInterface) + │ + └─ Photon doesn't have it → ACTUALLY DELETED (remove from cache) +``` + +**Why immediate?** Don't wait for user request. When closure detected for compressible AMM, query Photon immediately to keep cache fresh. This ensures quotes/swaps are always ready. + +**Future optimization:** If Photon adds streaming support for compressed account updates, aggregator could subscribe directly. + +### 6.2 What About Decompression Detection? + +When a cold account gets loaded (decompressed): +- Account appears on-chain again +- Stream delivers the account normally +- SDK can just use the hot account, drop ColdSpec + +**No special handling needed** - hot accounts work automatically. + +### 6.3 How Does Aggregator Know Program is Compressible? + +**Options:** +1. **Registry lookup** (recommended) - aggregator maintains list +2. **On-chain config** - program stores compressibility flag +3. **Trait check** - runtime downcast (requires `'static`) + +**Recommendation:** Registry lookup is simplest and most flexible. + +### 6.4 Transaction Size Limits + +Load instructions add to transaction size. With multiple cold accounts: +- May need to split into multiple transactions +- Or use address lookup tables (ALTs) +- Or batch load instructions efficiently + +**Typical sizes:** +- Load instruction per PDA: ~500-800 bytes (depends on proof size) +- Solana tx limit: 1232 bytes +- With ALTs: can fit more + +**Recommendation:** SDK provides `create_load_instructions` that handles batching internally. Aggregator may need to handle multi-tx scenarios for many cold accounts. + +### 6.5 Idempotency + +Load instructions should be **idempotent** - safe to retry if account already loaded. + +```rust +// On-chain check in decompress instruction: +if account_exists_and_matches(hot_address, expected_data) { + return Ok(()); // Already loaded, no-op +} +``` + +This allows aggregator to include load instructions even if account might have been loaded by another tx in the same slot. + +--- + +## Part 7: Implementation Checklist + +### For Light Protocol (SDK Provider) + +- [ ] Define `CompressibleAmm` trait with: + - [ ] `get_compressible_accounts()` - subset that could be cold + - [ ] `update_with_interfaces()` - update from Photon + - [ ] `get_cold_specs()` - returns lean `ColdAccountSpec` + - [ ] `has_cold_accounts()` - check if any cold +- [ ] Define `ColdAccountSpec` enum (Pda, Token, Mint) - NO redundant Account data +- [ ] Implement converter helpers: `AccountInterface` → `ColdAccountSpec` +- [ ] Implement `create_load_instructions` that takes `Vec>` +- [ ] Ensure `Variant.pack()` is correct and deterministic +- [ ] Provide reference implementation in test AMM SDK +- [ ] Document macro usage for generating `Variant` types + +### For Aggregator (Jupiter/DFlow) + +- [ ] Add compressibility flag to AMM registry +- [ ] Integrate Photon client +- [ ] Cache `get_compressible_accounts()` per AMM at registration +- [ ] On closure: check if pubkey is in compressible set before querying Photon +- [ ] Hot accounts: use `update(AccountMap)` from stream +- [ ] Cold accounts: use `update_with_interfaces()` from Photon +- [ ] Handle load instruction prepending +- [ ] Test with Light Protocol test AMM +- [ ] Monitor and iterate + +--- + +## Appendix A: Variant Construction + +The `Variant` type is generated by the `#[light_program]` macro. Understanding its structure is critical: + +```rust +// Generated by macro for a pool state account +pub enum LightAccountVariant { + PoolState { + data: PoolState, // The deserialized account struct + amm_config: Pubkey, // Seed value 1 (from data.amm_config) + token_0_mint: Pubkey, // Seed value 2 (from data.token_0_mint) + token_1_mint: Pubkey, // Seed value 3 (from data.token_1_mint) + }, + ObservationState { + data: ObservationState, + pool_state: Pubkey, // Seed value (from data.pool_state) + }, +} +``` + +**Key insight:** Seeds are COPIED from the account data at parse time. + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ VARIANT CONSTRUCTION │ +└─────────────────────────────────────────────────────────────────────────────────┘ + + AccountInterface.data() → raw bytes + │ + ▼ + ┌───────────────────────┐ + │ Deserialize struct │ + │ │ + │ pool_state = PoolState│ + │ ::deserialize( │ + │ &data[8..]) │ + └───────────────────────┘ + │ + ▼ + ┌───────────────────────┐ + │ Extract seeds from │ + │ parsed struct fields │ + │ │ + │ amm_config = │ + │ pool_state.amm_ │ + │ config │ + │ │ + │ token_0_mint = │ + │ pool_state.token_ │ + │ 0_mint │ + └───────────────────────┘ + │ + ▼ + ┌───────────────────────┐ + │ Construct Variant │ + │ │ + │ LightAccountVariant:: │ + │ PoolState { │ + │ data: pool_state, │ + │ amm_config, │ + │ token_0_mint, │ + │ token_1_mint, │ + │ } │ + └───────────────────────┘ + │ + ▼ + variant.pack() serializes: + - discriminator + - struct data + - seeds into remaining_accounts +``` + +**This is why ColdSpec needs Variant, not just raw bytes:** +- Raw bytes alone don't have explicit seed values extracted +- Variant has seeds ready for `pack()` to use +- `pack()` puts seeds into `remaining_accounts` for on-chain PDA verification + +--- + +## Appendix B: Photon API + +```rust +/// Photon indexer client interface +trait PhotonClient { + /// Get account interface - determines type automatically from pubkey. + /// Returns AccountInterface with ColdContext if compressed. + async fn get_account_interface(&self, pubkey: &Pubkey) -> Result; + + /// Batch version + async fn get_account_interfaces(&self, pubkeys: &[Pubkey]) -> Result>; + + /// Get validity proof for compressed account hashes + async fn get_validity_proof(&self, hashes: Vec<[u8; 32]>) -> Result; +} +``` + +**Key property:** `get_account_interface(pubkey)` needs NO hints. +Photon determines account type (PDA/Token/ATA/Mint) internally. +API matches RPC signature - drop-in replacement. + +--- + +## Appendix C: Complete Aggregator Example + +```rust +/// Complete aggregator integration example +struct Aggregator { + stream: LaserStream, + rpc: RpcClient, + photon: PhotonClient, + amm_cache: HashMap>, + compressible_programs: HashSet, + // Cache of which accounts could be cold per AMM + compressible_accounts: HashMap>, // amm_key → compressible pubkeys +} + +impl Aggregator { + /// Initialize AMM and cache its compressible accounts + fn register_amm(&mut self, amm: Box) { + let amm_key = amm.key(); + + // Cache compressible accounts if this is a compressible AMM + if self.compressible_programs.contains(&amm.program_id()) { + if let Some(compressible) = as_compressible(amm.as_ref()) { + let set: HashSet = compressible + .get_compressible_accounts() + .into_iter() + .collect(); + self.compressible_accounts.insert(amm_key, set); + } + } + + self.amm_cache.insert(amm_key, amm); + } + + /// Main streaming loop + async fn run(&mut self) { + loop { + match self.stream.next().await { + StreamEvent::AccountUpdate { pubkey, account } => { + self.handle_account_update(pubkey, account).await; + } + StreamEvent::Slot { .. } => { /* update slot */ } + } + } + } + + /// Handle account update from stream + async fn handle_account_update(&mut self, pubkey: Pubkey, account: Option) { + let affected_amms: Vec = self.find_amms_watching(&pubkey); + + for amm_key in affected_amms { + let amm = self.amm_cache.get_mut(&amm_key).unwrap(); + + match account { + Some(acc) if acc.lamports > 0 => { + // HOT - use regular update path + let map = HashMap::from([(pubkey, acc)]); + amm.update(&map).ok(); + } + _ => { + // CLOSED - check if this specific account could be cold + let could_be_cold = self.compressible_accounts + .get(&amm_key) + .map(|set| set.contains(&pubkey)) + .unwrap_or(false); + + if could_be_cold { + // Query Photon for this specific account + if let Ok(interface) = self.photon.get_account_interface(&pubkey).await { + if let Some(compressible) = as_compressible_mut(amm.as_mut()) { + compressible.update_with_interfaces(&[interface]).ok(); + } + } + } + // If not compressible or Photon returns nothing → truly deleted + } + } + } + } + + /// Build swap transaction + async fn build_swap(&self, amm_key: &Pubkey, params: SwapParams) -> Result { + let amm = self.amm_cache.get(amm_key).unwrap(); + let mut instructions = Vec::new(); + + // Check for cold accounts and prepend load instructions + if let Some(compressible) = as_compressible(amm.as_ref()) { + if compressible.has_cold_accounts() { + let cold_specs = compressible.get_cold_specs(); + let load_ixs = create_load_instructions(&cold_specs, params.payer).await?; + instructions.extend(load_ixs); + } + } + + // Build swap instruction (same for hot/cold) + let swap = amm.get_swap_and_account_metas(¶ms)?; + instructions.push(build_ix(swap)); + + Ok(build_transaction(instructions, params.payer)) + } +} + +/// Helper to downcast to CompressibleAmm +fn as_compressible(amm: &dyn Amm) -> Option<&dyn CompressibleAmm> { + // Implementation depends on how traits are structured + // Option 1: Use Any + downcast + // Option 2: Registry lookup + // Option 3: Method on Amm trait + todo!() +} +``` + +--- + +## Appendix D: Summary of Changes + +### Light Protocol Delivers: + +1. `CompressibleAmm` trait with 4 methods: + - `get_compressible_accounts()` - which accounts could be cold + - `update_with_interfaces()` - update from Photon + - `get_cold_specs()` - returns lean `ColdAccountSpec` + - `has_cold_accounts()` - check if any cold +2. `ColdAccountSpec` enum - lean, NO redundant Account data: + - `Pda { key, compressed, variant, program_id }` + - `Token { key, compressed }` + - `Mint { key, compressed }` +3. Converter helpers: `AccountInterface` → `ColdAccountSpec` +4. `create_load_instructions()` function +5. `#[light_program]` macro generates `Variant` types +6. Reference implementation in test AMM SDK +7. Documentation (this document) + +### Aggregator Implements: + +1. Compressible program registry +2. Photon client integration +3. Cache `get_compressible_accounts()` per AMM +4. On closure: check compressible set → query Photon if needed +5. Hot path: `update(AccountMap)` from stream +6. Cold path: `update_with_interfaces()` from Photon +7. Load instruction prepending in swap building +8. (Optional) Compress transaction log watching + +### No Changes Required: + +1. Jupiter `Amm` trait - unchanged +2. `get_accounts_to_update()` - unchanged (returns all accounts) +3. Existing non-compressible AMMs - unchanged +4. Quote logic - unchanged (reads from memory) +5. Stream subscription - unchanged (same pubkeys) From 6106ed2bb425928c5e0b5bd304b724efce7c4d4e Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Wed, 28 Jan 2026 19:59:18 +0000 Subject: [PATCH 4/6] wip - trait refactor --- Cargo.lock | 160 +++++- .../src/interface/light_program_interface.rs | 221 +++++++-- sdk-libs/client/src/interface/mod.rs | 2 +- .../Cargo.toml | 7 + .../src/lib.rs | 425 +++++++++++++++- .../tests/trait_tests.rs | 116 ++--- .../csdk-anchor-full-derived-test/Cargo.toml | 1 + .../tests/amm_test.rs | 466 +++++++++++++++++- 8 files changed, 1271 insertions(+), 127 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e9a9321d1f..0f1585095a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,7 +119,7 @@ version = "2.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d52a2c365c0245cbb8959de725fc2b44c754b673fdf34c9a7f9d4a25c35a7bf1" dependencies = [ - "ahash", + "ahash 0.8.12", "solana-epoch-schedule", "solana-hash 2.3.0", "solana-pubkey 2.4.0", @@ -160,6 +160,17 @@ dependencies = [ "solana-sdk-ids", ] +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "version_check", +] + [[package]] name = "ahash" version = "0.8.12" @@ -548,7 +559,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" dependencies = [ - "ahash", + "ahash 0.8.12", "ark-ff 0.5.0", "ark-poly 0.5.0", "ark-serialize 0.5.0", @@ -668,7 +679,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" dependencies = [ - "ahash", + "ahash 0.8.12", "ark-ff 0.5.0", "ark-serialize 0.5.0", "ark-std 0.5.0", @@ -1136,6 +1147,28 @@ dependencies = [ "serde", ] +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "bytecount" version = "0.6.9" @@ -1621,6 +1654,7 @@ dependencies = [ "bincode", "borsh 0.10.4", "csdk-anchor-full-derived-test-sdk", + "jupiter-amm-interface", "light-anchor-spl", "light-client", "light-compressed-account", @@ -1660,13 +1694,18 @@ dependencies = [ name = "csdk-anchor-full-derived-test-sdk" version = "0.1.0" dependencies = [ - "ahash", + "ahash 0.8.12", "anchor-lang", + "anyhow", "csdk-anchor-full-derived-test", + "jupiter-amm-interface", "light-client", "light-compressed-token-sdk", "light-sdk", "light-token", + "light-token-interface", + "rust_decimal", + "solana-account", "solana-pubkey 2.4.0", ] @@ -2692,6 +2731,9 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] [[package]] name = "hashbrown" @@ -2699,7 +2741,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" dependencies = [ - "ahash", + "ahash 0.8.12", ] [[package]] @@ -3362,6 +3404,22 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jupiter-amm-interface" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db91d39569a7d9b2b0cd1007075abde274a590e57f8897cde517c6546d098638" +dependencies = [ + "ahash 0.8.12", + "anyhow", + "borsh 0.10.4", + "rust_decimal", + "serde", + "serde_json", + "solana-account-decoder", + "solana-sdk", +] + [[package]] name = "kaigan" version = "0.2.6" @@ -5272,6 +5330,26 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "qstring" version = "0.7.2" @@ -5638,6 +5716,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + [[package]] name = "reqwest" version = "0.11.27" @@ -5758,6 +5845,35 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "rpassword" version = "7.4.0" @@ -5779,6 +5895,22 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rust_decimal" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +dependencies = [ + "arrayvec", + "borsh 1.6.0", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", +] + [[package]] name = "rustc-demangle" version = "0.1.26" @@ -6180,6 +6312,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + [[package]] name = "security-framework" version = "2.11.1" @@ -6486,6 +6624,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "single-ata-test" version = "0.1.0" @@ -6989,7 +7133,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ca69a299a6c969b18ea381a02b40c9e4dda04b2af0d15a007c1184c82163bbb" dependencies = [ "agave-feature-set", - "ahash", + "ahash 0.8.12", "log", "solana-bpf-loader-program", "solana-compute-budget-program", @@ -7458,7 +7602,7 @@ version = "2.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93b93971e289d6425f88e6e3cb6668c4b05df78b3c518c249be55ced8efd6b6d" dependencies = [ - "ahash", + "ahash 0.8.12", "lazy_static", "solana-epoch-schedule", "solana-hash 2.3.0", @@ -7912,7 +8056,7 @@ version = "2.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37192c0be5c222ca49dbc5667288c5a8bb14837051dd98e541ee4dad160a5da9" dependencies = [ - "ahash", + "ahash 0.8.12", "bincode", "bv", "bytes", diff --git a/sdk-libs/client/src/interface/light_program_interface.rs b/sdk-libs/client/src/interface/light_program_interface.rs index 7815037bc2..19009975a0 100644 --- a/sdk-libs/client/src/interface/light_program_interface.rs +++ b/sdk-libs/client/src/interface/light_program_interface.rs @@ -2,9 +2,11 @@ //! //! Core types: //! - `ColdContext` - Cold data context (Account or Token) +//! - `ColdAccountSpec` - Lean cold spec without redundant Account struct //! - `PdaSpec` - Spec for PDA loading with typed variant //! - `AccountSpec` - Unified spec enum for load instruction building -//! - `LightProgramInterface` - Trait for program SDKs +//! - `LightProgramInterface` - Base trait for program SDKs with per-instruction granularity +//! - `LightAmmInterface` - Extension trait for swap-focused AMM SDKs use std::fmt::Debug; @@ -72,6 +74,105 @@ pub enum ColdContext { Token(CompressedTokenAccount), } +/// Lean cold account spec - NO redundant `Account` struct. +/// +/// This is the internal storage format for cold accounts in SDKs. +/// Unlike `AccountSpec` which wraps `AccountInterface`, this only stores +/// what's actually needed for building load instructions. +#[derive(Clone, Debug)] +pub enum ColdAccountSpec { + /// Program-owned PDA - needs Variant for pack() + Pda { + key: Pubkey, + compressed: CompressedAccount, + variant: V, + program_id: Pubkey, + }, + /// Program-owned token account (vault) + Token { + key: Pubkey, + compressed: CompressedTokenAccount, + }, + /// Compressed mint + Mint { + key: Pubkey, + compressed: CompressedAccount, + }, +} + +impl ColdAccountSpec { + /// Create a PDA spec from AccountInterface + variant. + pub fn from_pda_interface( + interface: &AccountInterface, + variant: V, + program_id: Pubkey, + ) -> Option { + let compressed = interface.as_compressed_account()?.clone(); + Some(Self::Pda { + key: interface.key, + compressed, + variant, + program_id, + }) + } + + /// Create a Token spec from AccountInterface. + pub fn from_token_interface(interface: &AccountInterface) -> Option { + let compressed = interface.as_compressed_token()?.clone(); + Some(Self::Token { + key: interface.key, + compressed, + }) + } + + /// Create a Mint spec from AccountInterface. + pub fn from_mint_interface(interface: &AccountInterface) -> Option { + let compressed = interface.as_compressed_account()?.clone(); + Some(Self::Mint { + key: interface.key, + compressed, + }) + } + + /// Get the account's public key. + #[must_use] + pub fn key(&self) -> Pubkey { + match self { + Self::Pda { key, .. } => *key, + Self::Token { key, .. } => *key, + Self::Mint { key, .. } => *key, + } + } + + /// Get the account hash (for proof fetching). + #[must_use] + pub fn hash(&self) -> [u8; 32] { + match self { + Self::Pda { compressed, .. } => compressed.hash, + Self::Token { compressed, .. } => compressed.account.hash, + Self::Mint { compressed, .. } => compressed.hash, + } + } + + /// Get the compressed account (for PDAs/mints). + #[must_use] + pub fn compressed_account(&self) -> Option<&CompressedAccount> { + match self { + Self::Pda { compressed, .. } | Self::Mint { compressed, .. } => Some(compressed), + Self::Token { .. } => None, + } + } + + /// Get the compressed token account (for tokens). + #[must_use] + pub fn compressed_token(&self) -> Option<&CompressedTokenAccount> { + match self { + Self::Token { compressed, .. } => Some(compressed), + _ => None, + } + } +} + /// Specification for a program-owned PDA with typed variant. /// /// Embeds `AccountInterface` for account data and adds `variant` for typed variant. @@ -214,13 +315,20 @@ pub fn all_hot(specs: &[AccountSpec]) -> bool { specs.iter().all(|s| s.is_hot()) } -/// Trait for programs to give clients a unified API to load cold program accounts. +/// Base trait for programs with compressible accounts. +/// +/// Provides per-instruction granularity for account discovery and cold spec retrieval. +/// Programs implement this trait to enable clients to: +/// 1. Fetch accounts needed for specific instructions +/// 2. Determine which accounts are cold and need loading +/// 3. Build load instructions for cold accounts pub trait LightProgramInterface: Sized { /// The program's interface account variant enum. + /// Generated by `#[light_program]` macro, contains parsed data + seed values. type Variant: Pack + Clone + Debug; - /// Program-specific instruction enum. - type Instruction; + /// Program-specific instruction kind enum (e.g., `Swap`, `Deposit`, `Withdraw`). + type InstructionKind: Copy + Debug; /// Error type for SDK operations. type Error: std::error::Error; @@ -229,49 +337,102 @@ pub trait LightProgramInterface: Sized { #[must_use] fn program_id(&self) -> Pubkey; - /// Construct SDK from root account(s). + /// Construct SDK from keyed account interfaces. fn from_keyed_accounts(accounts: &[AccountInterface]) -> Result; - /// Returns pubkeys of accounts needed for an instruction. + /// Returns ALL compressible account pubkeys (accounts that could be cold). + /// Used for initial fetch and caching. #[must_use] - fn get_accounts_to_update(&self, ix: &Self::Instruction) -> Vec; + fn get_compressible_accounts(&self) -> Vec; - /// Update internal cache with fetched account data. - fn update(&mut self, accounts: &[AccountInterface]) -> Result<(), Self::Error>; + // TODO: Replace AccountToFetch with just Pubkey once Photon can determine type from pubkey alone. + /// Returns accounts needed for a specific instruction. + #[must_use] + fn get_accounts_for_instruction(&self, kind: Self::InstructionKind) -> Vec; - /// Get all cached specs. + /// Returns compressible accounts needed for a specific instruction. + /// This is a subset of `get_accounts_for_instruction()`. #[must_use] - fn get_all_specs(&self) -> Vec>; + fn get_compressible_accounts_for_instruction( + &self, + kind: Self::InstructionKind, + ) -> Vec; - /// Get specs filtered for a specific instruction. + /// Update internal cache from account interfaces. + /// Works uniformly for hot/cold accounts. + /// Named `update_with_interfaces` to avoid collision with Jupiter's `Amm::update`. + fn update_with_interfaces(&mut self, accounts: &[AccountInterface]) -> Result<(), Self::Error>; + + /// Get all cached specs (hot and cold). #[must_use] - fn get_specs_for_instruction(&self, ix: &Self::Instruction) -> Vec>; + fn get_all_specs(&self) -> Vec>; - /// Get only cold specs from all cached specs. + /// Get specs for a specific instruction. #[must_use] - fn get_cold_specs(&self) -> Vec> { - self.get_all_specs() - .into_iter() - .filter(|s| s.is_cold()) - .collect() - } + fn get_specs_for_instruction( + &self, + kind: Self::InstructionKind, + ) -> Vec>; - /// Get only cold specs for a specific instruction. + /// Get lean cold specs for a specific instruction. + /// Returns `ColdAccountSpec` which has NO redundant `Account` data. #[must_use] fn get_cold_specs_for_instruction( &self, - ix: &Self::Instruction, - ) -> Vec> { - self.get_specs_for_instruction(ix) - .into_iter() - .filter(|s| s.is_cold()) - .collect() - } + kind: Self::InstructionKind, + ) -> Vec>; /// Check if any accounts for this instruction are cold. #[must_use] - fn needs_loading(&self, ix: &Self::Instruction) -> bool { - any_cold(&self.get_specs_for_instruction(ix)) + fn has_cold_accounts_for_instruction(&self, kind: Self::InstructionKind) -> bool { + !self.get_cold_specs_for_instruction(kind).is_empty() + } + + /// Check if any cached accounts are cold. + #[must_use] + fn has_any_cold_accounts(&self) -> bool { + any_cold(&self.get_all_specs()) + } +} + +/// Extension trait for AMM SDKs that support swap operations. +/// +/// This is a convenience layer over `LightProgramInterface` that pins +/// the instruction kind to "Swap". Aggregators use this trait. +pub trait LightAmmInterface: LightProgramInterface { + /// The swap instruction kind for this AMM. + fn swap_instruction_kind(&self) -> Self::InstructionKind; + + /// Get accounts needed for swap. + /// Equivalent to `get_accounts_for_instruction(swap_instruction_kind())`. + #[must_use] + fn get_swap_accounts(&self) -> Vec { + self.get_accounts_for_instruction(self.swap_instruction_kind()) + } + + /// Get compressible accounts needed for swap. + #[must_use] + fn get_compressible_swap_accounts(&self) -> Vec { + self.get_compressible_accounts_for_instruction(self.swap_instruction_kind()) + } + + /// Get specs for swap instruction. + #[must_use] + fn get_swap_specs(&self) -> Vec> { + self.get_specs_for_instruction(self.swap_instruction_kind()) + } + + /// Get lean cold specs for swap accounts only. + /// This is what aggregators call before building load instructions. + #[must_use] + fn get_cold_swap_specs(&self) -> Vec> { + self.get_cold_specs_for_instruction(self.swap_instruction_kind()) + } + + /// Check if swap requires loading cold accounts. + #[must_use] + fn swap_needs_loading(&self) -> bool { + self.has_cold_accounts_for_instruction(self.swap_instruction_kind()) } } diff --git a/sdk-libs/client/src/interface/mod.rs b/sdk-libs/client/src/interface/mod.rs index b8847c6e98..74be90b9fe 100644 --- a/sdk-libs/client/src/interface/mod.rs +++ b/sdk-libs/client/src/interface/mod.rs @@ -24,7 +24,7 @@ pub use initialize_config::InitializeRentFreeConfig; pub use light_compressible::CreateAccountsProof; pub use light_program_interface::{ all_hot, any_cold, discriminator, matches_discriminator, AccountSpec, AccountToFetch, - ColdContext, LightProgramInterface, PdaSpec, + ColdAccountSpec, ColdContext, LightAmmInterface, LightProgramInterface, PdaSpec, }; pub use light_sdk::interface::config::LightConfig; pub use light_token::compat::TokenData; diff --git a/sdk-tests/csdk-anchor-full-derived-test-sdk/Cargo.toml b/sdk-tests/csdk-anchor-full-derived-test-sdk/Cargo.toml index 25dcb7f470..5b1d16af41 100644 --- a/sdk-tests/csdk-anchor-full-derived-test-sdk/Cargo.toml +++ b/sdk-tests/csdk-anchor-full-derived-test-sdk/Cargo.toml @@ -12,10 +12,17 @@ csdk-anchor-full-derived-test = { path = "../csdk-anchor-full-derived-test", fea light-client = { workspace = true, features = ["v2", "anchor"] } light-sdk = { workspace = true, features = ["anchor", "v2"] } light-token = { workspace = true, features = ["anchor"] } +light-token-interface = { workspace = true, features = ["anchor"] } light-compressed-token-sdk = { workspace = true, features = ["anchor"] } anchor-lang = { workspace = true } solana-pubkey = { workspace = true } +solana-account = { workspace = true } # Fast hashing for account maps ahash = "0.8" + +# Jupiter integration +jupiter-amm-interface = "0.6" +anyhow = "1.0" +rust_decimal = "1.32" diff --git a/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs b/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs index dcb727ed56..908f702090 100644 --- a/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs +++ b/sdk-tests/csdk-anchor-full-derived-test-sdk/src/lib.rs @@ -1,7 +1,7 @@ //! Client SDK for the AMM test program. //! -//! Implements the `LightProgramInterface` trait to provide a Jupiter-style -//! interface for clients to build decompression instructions. +//! Implements `LightProgramInterface` and `LightAmmInterface` traits to provide +//! a Jupiter-style interface for clients to build decompression instructions. use std::collections::HashMap; @@ -13,8 +13,8 @@ use csdk_anchor_full_derived_test::{ }, }; use light_client::interface::{ - matches_discriminator, AccountInterface, AccountSpec, AccountToFetch, ColdContext, - CreateAccountsProofInput, LightProgramInterface, PdaSpec, + matches_discriminator, AccountInterface, AccountSpec, AccountToFetch, ColdAccountSpec, + ColdContext, CreateAccountsProofInput, LightAmmInterface, LightProgramInterface, PdaSpec, }; use light_sdk::LightDiscriminator; use solana_pubkey::Pubkey; @@ -39,14 +39,21 @@ pub enum AccountKind { pub struct AccountRequirement { pub pubkey: Option, pub kind: AccountKind, + /// Whether this account is compressible (can go cold). + pub compressible: bool, } impl AccountRequirement { - fn new(pubkey: Option, kind: AccountKind) -> Self { - Self { pubkey, kind } + fn new(pubkey: Option, kind: AccountKind, compressible: bool) -> Self { + Self { + pubkey, + kind, + compressible, + } } } +/// Instruction kinds for the AMM program. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AmmInstruction { Swap, @@ -75,7 +82,7 @@ impl std::fmt::Display for AmmSdkError { impl std::error::Error for AmmSdkError {} -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct AmmSdk { pool_state_pubkey: Option, amm_config: Option, @@ -188,13 +195,38 @@ impl AmmSdk { is_vault_0: bool, ) -> Result<(), AmmSdkError> { use light_token::compat::TokenData; + use light_token_interface::state::Token; let pool_state = self .pool_state_pubkey .ok_or(AmmSdkError::PoolStateNotParsed)?; - let token_data = TokenData::deserialize(&mut &account.data()[..]) - .map_err(|e| AmmSdkError::ParseError(e.to_string()))?; + // Hot accounts use SPL-compatible Token layout, cold use compressed TokenData + let token_data = if account.is_hot() { + // On-chain accounts use Token struct (SPL-compatible layout) + let token = Token::deserialize(&mut &account.data()[..]) + .map_err(|e| AmmSdkError::ParseError(format!("Token deser error: {} data_len={}", e, account.data().len())))?; + // Convert Token to compressed TokenData format + TokenData { + mint: solana_pubkey::Pubkey::new_from_array(token.mint.to_bytes()), + owner: solana_pubkey::Pubkey::new_from_array(token.owner.to_bytes()), + amount: token.amount, + delegate: token + .delegate + .map(|d| solana_pubkey::Pubkey::new_from_array(d.to_bytes())), + state: match token.state { + light_token_interface::state::AccountState::Initialized => { + light_token::compat::AccountState::Initialized + } + _ => light_token::compat::AccountState::Frozen, + }, + tlv: None, + } + } else { + // Compressed accounts use TokenData format directly + TokenData::deserialize(&mut &account.data()[..]) + .map_err(|e| AmmSdkError::ParseError(e.to_string()))? + }; let variant = if is_vault_0 { let token_0_mint = self @@ -286,32 +318,46 @@ impl AmmSdk { }) } - fn account_requirements(&self, ix: &AmmInstruction) -> Vec { - match ix { + fn account_requirements(&self, kind: AmmInstruction) -> Vec { + match kind { AmmInstruction::Swap => { vec![ - AccountRequirement::new(self.pool_state_pubkey, AccountKind::Pda), - AccountRequirement::new(self.token_0_vault, AccountKind::Token), - AccountRequirement::new(self.token_1_vault, AccountKind::Token), - AccountRequirement::new(self.observation_key, AccountKind::Pda), + AccountRequirement::new(self.pool_state_pubkey, AccountKind::Pda, true), + AccountRequirement::new(self.token_0_vault, AccountKind::Token, true), + AccountRequirement::new(self.token_1_vault, AccountKind::Token, true), + AccountRequirement::new(self.observation_key, AccountKind::Pda, true), ] } AmmInstruction::Deposit | AmmInstruction::Withdraw => { vec![ - AccountRequirement::new(self.pool_state_pubkey, AccountKind::Pda), - AccountRequirement::new(self.token_0_vault, AccountKind::Token), - AccountRequirement::new(self.token_1_vault, AccountKind::Token), - AccountRequirement::new(self.observation_key, AccountKind::Pda), - AccountRequirement::new(self.lp_mint, AccountKind::Mint), + AccountRequirement::new(self.pool_state_pubkey, AccountKind::Pda, true), + AccountRequirement::new(self.token_0_vault, AccountKind::Token, true), + AccountRequirement::new(self.token_1_vault, AccountKind::Token, true), + AccountRequirement::new(self.observation_key, AccountKind::Pda, true), + AccountRequirement::new(self.lp_mint, AccountKind::Mint, true), ] } } } + + /// Get all compressible account pubkeys. + fn all_compressible_accounts(&self) -> Vec { + [ + self.pool_state_pubkey, + self.token_0_vault, + self.token_1_vault, + self.observation_key, + self.lp_mint, + ] + .into_iter() + .flatten() + .collect() + } } impl LightProgramInterface for AmmSdk { type Variant = LightAccountVariant; - type Instruction = AmmInstruction; + type InstructionKind = AmmInstruction; type Error = AmmSdkError; fn program_id(&self) -> Pubkey { @@ -333,8 +379,12 @@ impl LightProgramInterface for AmmSdk { Ok(sdk) } - fn get_accounts_to_update(&self, ix: &Self::Instruction) -> Vec { - self.account_requirements(ix) + fn get_compressible_accounts(&self) -> Vec { + self.all_compressible_accounts() + } + + fn get_accounts_for_instruction(&self, kind: Self::InstructionKind) -> Vec { + self.account_requirements(kind) .into_iter() .filter_map(|req| { req.pubkey.map(|pubkey| match req.kind { @@ -346,8 +396,36 @@ impl LightProgramInterface for AmmSdk { .collect() } - fn update(&mut self, accounts: &[AccountInterface]) -> Result<(), Self::Error> { + fn get_compressible_accounts_for_instruction( + &self, + kind: Self::InstructionKind, + ) -> Vec { + self.account_requirements(kind) + .into_iter() + .filter_map(|req| if req.compressible { req.pubkey } else { None }) + .collect() + } + + fn update_with_interfaces(&mut self, accounts: &[AccountInterface]) -> Result<(), Self::Error> { for account in accounts { + // Handle decompression: if account was cold but now hot, remove from specs + if account.is_hot() { + // Remove stale cold entry if account is now hot + if self + .program_owned_specs + .get(&account.key) + .map_or(false, |s| s.is_cold()) + { + self.program_owned_specs.remove(&account.key); + } + if self + .mint_specs + .get(&account.key) + .map_or(false, |s| s.is_cold()) + { + self.mint_specs.remove(&account.key); + } + } self.parse_account(account)?; } Ok(()) @@ -365,8 +443,11 @@ impl LightProgramInterface for AmmSdk { specs } - fn get_specs_for_instruction(&self, ix: &Self::Instruction) -> Vec> { - let requirements = self.account_requirements(ix); + fn get_specs_for_instruction( + &self, + kind: Self::InstructionKind, + ) -> Vec> { + let requirements = self.account_requirements(kind); let mut specs = Vec::new(); for req in &requirements { @@ -390,6 +471,77 @@ impl LightProgramInterface for AmmSdk { specs } + + fn get_cold_specs_for_instruction( + &self, + kind: Self::InstructionKind, + ) -> Vec> { + let requirements = self.account_requirements(kind); + let mut cold_specs = Vec::new(); + + for req in &requirements { + if !req.compressible { + continue; + } + match req.kind { + AccountKind::Pda => { + if let Some(pubkey) = req.pubkey { + if let Some(spec) = self.program_owned_specs.get(&pubkey) { + if spec.is_cold() { + if let Some(compressed) = spec.compressed() { + cold_specs.push(ColdAccountSpec::Pda { + key: pubkey, + compressed: compressed.clone(), + variant: spec.variant.clone(), + program_id: PROGRAM_ID, + }); + } + } + } + } + } + AccountKind::Token => { + if let Some(pubkey) = req.pubkey { + if let Some(spec) = self.program_owned_specs.get(&pubkey) { + if spec.is_cold() { + // Token vaults use ColdContext::Account after conversion + if let Some(compressed) = spec.compressed() { + cold_specs.push(ColdAccountSpec::Pda { + key: pubkey, + compressed: compressed.clone(), + variant: spec.variant.clone(), + program_id: PROGRAM_ID, + }); + } + } + } + } + } + AccountKind::Mint => { + if let Some(mint_pubkey) = req.pubkey { + if let Some(spec) = self.mint_specs.get(&mint_pubkey) { + if spec.is_cold() { + if let Some(compressed) = spec.as_compressed_account() { + cold_specs.push(ColdAccountSpec::Mint { + key: mint_pubkey, + compressed: compressed.clone(), + }); + } + } + } + } + } + } + } + + cold_specs + } +} + +impl LightAmmInterface for AmmSdk { + fn swap_instruction_kind(&self) -> Self::InstructionKind { + AmmInstruction::Swap + } } impl AmmSdk { @@ -454,4 +606,225 @@ impl AmmSdk { CreateAccountsProofInput::mint(lp_mint), ] } + + /// Get reserve vault amounts for quoting. + pub fn get_vault_amounts(&self) -> Option<(u64, u64)> { + let vault_0 = self.token_0_vault?; + let vault_1 = self.token_1_vault?; + + let spec_0 = self.program_owned_specs.get(&vault_0)?; + let spec_1 = self.program_owned_specs.get(&vault_1)?; + + // Parse token amounts from variant + let amount_0 = match &spec_0.variant { + LightAccountVariant::CTokenData(ct) => ct.token_data.amount, + _ => return None, + }; + let amount_1 = match &spec_1.variant { + LightAccountVariant::CTokenData(ct) => ct.token_data.amount, + _ => return None, + }; + + Some((amount_0, amount_1)) + } +} + +// ============================================================================= +// Jupiter Amm Trait Implementation +// ============================================================================= + +mod jupiter_impl { + use super::*; + use jupiter_amm_interface::{ + AccountMap, Amm, AmmContext, KeyedAccount, Quote, QuoteParams, SwapAndAccountMetas, + SwapParams, + }; + + /// Error type for Jupiter Amm trait operations. + #[derive(Debug)] + pub struct JupiterAmmError(pub String); + + impl std::fmt::Display for JupiterAmmError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "JupiterAmmError: {}", self.0) + } + } + + impl std::error::Error for JupiterAmmError {} + + impl From for JupiterAmmError { + fn from(e: AmmSdkError) -> Self { + JupiterAmmError(e.to_string()) + } + } + + impl Amm for AmmSdk { + fn from_keyed_account( + keyed_account: &KeyedAccount, + _amm_context: &AmmContext, + ) -> Result + where + Self: Sized, + { + let interface = AccountInterface::hot(keyed_account.key, keyed_account.account.clone()); + let sdk = ::from_keyed_accounts(&[interface]) + .map_err(|e| anyhow::anyhow!("{}", e))?; + Ok(sdk) + } + + fn label(&self) -> String { + "LightAMM".to_string() + } + + fn program_id(&self) -> Pubkey { + PROGRAM_ID + } + + fn key(&self) -> Pubkey { + self.pool_state_pubkey.unwrap_or_default() + } + + fn get_reserve_mints(&self) -> Vec { + [self.token_0_mint, self.token_1_mint] + .into_iter() + .flatten() + .collect() + } + + fn get_accounts_to_update(&self) -> Vec { + // For Jupiter, return all accounts for swap + self.get_swap_accounts() + .into_iter() + .map(|a| a.pubkey()) + .collect() + } + + fn update(&mut self, account_map: &AccountMap) -> Result<(), anyhow::Error> { + // Convert AccountMap entries to AccountInterface + let interfaces: Vec = account_map + .iter() + .map(|(key, account)| AccountInterface::hot(*key, account.clone())) + .collect(); + + self.update_with_interfaces(&interfaces) + .map_err(|e| anyhow::anyhow!("{}", e)) + } + + fn quote(&self, params: &QuoteParams) -> Result { + // Simple constant product quote for testing + let (reserve_0, reserve_1) = self + .get_vault_amounts() + .ok_or_else(|| anyhow::anyhow!("Missing vault amounts"))?; + + let (input_reserve, output_reserve) = if params.input_mint == self.token_0_mint.unwrap() + { + (reserve_0, reserve_1) + } else { + (reserve_1, reserve_0) + }; + + // Constant product: (x + dx) * (y - dy) = x * y + // dy = y * dx / (x + dx) + let input_amount = params.amount; + let output_amount = (output_reserve as u128) + .checked_mul(input_amount as u128) + .and_then(|n| n.checked_div((input_reserve as u128) + (input_amount as u128))) + .ok_or_else(|| anyhow::anyhow!("Quote calculation overflow"))? + as u64; + + Ok(Quote { + in_amount: input_amount, + out_amount: output_amount, + fee_amount: 0, + fee_mint: params.input_mint, + fee_pct: rust_decimal::Decimal::ZERO, + }) + } + + fn get_swap_and_account_metas( + &self, + params: &SwapParams, + ) -> Result { + use anchor_lang::ToAccountMetas; + use csdk_anchor_full_derived_test::amm_test::TradeDirection; + + let pool_state = self + .pool_state_pubkey + .ok_or_else(|| anyhow::anyhow!("Pool state not set"))?; + let authority = self + .authority + .ok_or_else(|| anyhow::anyhow!("Authority not set"))?; + let observation = self + .observation_key + .ok_or_else(|| anyhow::anyhow!("Observation not set"))?; + + // Determine direction based on input mint + let is_zero_for_one = params.source_mint == self.token_0_mint.unwrap(); + let (input_vault, output_vault, input_mint, output_mint) = if is_zero_for_one { + ( + self.token_0_vault.unwrap(), + self.token_1_vault.unwrap(), + self.token_0_mint.unwrap(), + self.token_1_mint.unwrap(), + ) + } else { + ( + self.token_1_vault.unwrap(), + self.token_0_vault.unwrap(), + self.token_1_mint.unwrap(), + self.token_0_mint.unwrap(), + ) + }; + + let accounts = csdk_anchor_full_derived_test::accounts::Swap { + payer: params.source_token_account, + authority, + pool_state, + input_token_account: params.source_token_account, + output_token_account: params.destination_token_account, + input_vault, + output_vault, + input_token_program: light_token::instruction::LIGHT_TOKEN_PROGRAM_ID, + output_token_program: light_token::instruction::LIGHT_TOKEN_PROGRAM_ID, + input_token_mint: input_mint, + output_token_mint: output_mint, + observation_state: observation, + }; + + let direction = if is_zero_for_one { + TradeDirection::ZeroForOne + } else { + TradeDirection::OneForZero + }; + + let _ix_data = csdk_anchor_full_derived_test::instruction::Swap { + amount_in: params.in_amount, + minimum_amount_out: params.out_amount, + direction, + }; + + Ok(SwapAndAccountMetas { + swap: jupiter_amm_interface::Swap::TokenSwap, + account_metas: accounts.to_account_metas(None), + }) + } + + fn clone_amm(&self) -> Box { + Box::new(self.clone()) + } + + fn has_dynamic_accounts(&self) -> bool { + false + } + + fn supports_exact_out(&self) -> bool { + false + } + + fn is_active(&self) -> bool { + self.pool_state_pubkey.is_some() + } + } } + +pub use jupiter_impl::JupiterAmmError; diff --git a/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs b/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs index efa9177db3..42eb89ede6 100644 --- a/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs +++ b/sdk-tests/csdk-anchor-full-derived-test-sdk/tests/trait_tests.rs @@ -6,6 +6,7 @@ //! - Multi-operation scenarios with overlapping/divergent accounts //! - Invariants (idempotency, commutativity, spec consistency) //! - Edge cases (hot/cold mixed, missing accounts, etc.) +//! - LightAmmInterface extension methods use std::collections::HashSet; @@ -15,7 +16,8 @@ use csdk_anchor_full_derived_test::{ }; use csdk_anchor_full_derived_test_sdk::{AmmInstruction, AmmSdk, AmmSdkError}; use light_client::interface::{ - all_hot, any_cold, Account, AccountInterface, AccountSpec, LightProgramInterface, PdaSpec, + all_hot, any_cold, Account, AccountInterface, AccountSpec, LightAmmInterface, + LightProgramInterface, PdaSpec, }; use light_sdk::LightDiscriminator; use solana_pubkey::Pubkey; @@ -105,7 +107,7 @@ fn test_from_keyed_zero_length_data() { } // ============================================================================= -// 2. CORE TRAIT METHOD TESTS: get_accounts_to_update +// 2. CORE TRAIT METHOD TESTS: get_accounts_for_instruction // ============================================================================= #[test] @@ -113,8 +115,8 @@ fn test_get_accounts_before_init() { // T1.2.4: Returns empty before pool parsed let sdk = AmmSdk::new(); - let swap_accounts = sdk.get_accounts_to_update(&AmmInstruction::Swap); - let deposit_accounts = sdk.get_accounts_to_update(&AmmInstruction::Deposit); + let swap_accounts = sdk.get_accounts_for_instruction(AmmInstruction::Swap); + let deposit_accounts = sdk.get_accounts_for_instruction(AmmInstruction::Deposit); assert!( swap_accounts.is_empty(), @@ -134,9 +136,9 @@ fn test_get_accounts_swap_vs_deposit() { let sdk = AmmSdk::new(); // Without pool state, both return empty - let _swap_accounts = sdk.get_accounts_to_update(&AmmInstruction::Swap); - let deposit_accounts = sdk.get_accounts_to_update(&AmmInstruction::Deposit); - let withdraw_accounts = sdk.get_accounts_to_update(&AmmInstruction::Withdraw); + let _swap_accounts = sdk.get_accounts_for_instruction(AmmInstruction::Swap); + let deposit_accounts = sdk.get_accounts_for_instruction(AmmInstruction::Deposit); + let withdraw_accounts = sdk.get_accounts_for_instruction(AmmInstruction::Withdraw); // Verify Deposit and Withdraw have same requirements assert_eq!( @@ -159,7 +161,7 @@ fn test_update_before_root_errors() { let vault_keyed = keyed_hot(Pubkey::new_unique(), vault_data); // This should either error or skip (depending on implementation) - let result = sdk.update(&[vault_keyed]); + let result = sdk.update_with_interfaces(&[vault_keyed]); // Current impl: skips unknown accounts, doesn't error assert!(result.is_ok(), "Update with unknown should skip, not error"); @@ -174,10 +176,10 @@ fn test_update_idempotent() { let keyed = keyed_hot(Pubkey::new_unique(), data.clone()); // Update twice with same data - let _ = sdk.update(std::slice::from_ref(&keyed)); + let _ = sdk.update_with_interfaces(std::slice::from_ref(&keyed)); let specs_after_first = sdk.get_all_specs(); - let _ = sdk.update(std::slice::from_ref(&keyed)); + let _ = sdk.update_with_interfaces(std::slice::from_ref(&keyed)); let specs_after_second = sdk.get_all_specs(); // Should be same @@ -196,7 +198,7 @@ fn test_update_unknown_account_skipped() { let unknown_data = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x00, 0x00, 0x00]; let keyed = keyed_hot(Pubkey::new_unique(), unknown_data); - let result = sdk.update(&[keyed]); + let result = sdk.update_with_interfaces(&[keyed]); assert!(result.is_ok(), "Unknown account should be skipped"); let specs = sdk.get_all_specs(); @@ -347,12 +349,12 @@ fn test_multi_op_deposit_superset_of_swap() { let sdk = AmmSdk::new(); let swap_accounts: HashSet = sdk - .get_accounts_to_update(&AmmInstruction::Swap) + .get_accounts_for_instruction(AmmInstruction::Swap) .into_iter() .map(|a| a.pubkey()) .collect(); let deposit_accounts: HashSet = sdk - .get_accounts_to_update(&AmmInstruction::Deposit) + .get_accounts_for_instruction(AmmInstruction::Deposit) .into_iter() .map(|a| a.pubkey()) .collect(); @@ -371,8 +373,8 @@ fn test_multi_op_withdraw_equals_deposit() { // T3.1: Withdraw should have same accounts as Deposit let sdk = AmmSdk::new(); - let deposit_accounts = sdk.get_accounts_to_update(&AmmInstruction::Deposit); - let withdraw_accounts = sdk.get_accounts_to_update(&AmmInstruction::Withdraw); + let deposit_accounts = sdk.get_accounts_for_instruction(AmmInstruction::Deposit); + let withdraw_accounts = sdk.get_accounts_for_instruction(AmmInstruction::Withdraw); assert_eq!( deposit_accounts, withdraw_accounts, @@ -397,10 +399,10 @@ fn test_same_pubkey_same_spec() { let keyed1 = keyed_hot(pubkey, data.clone()); let keyed2 = keyed_hot(pubkey, data.clone()); - let _ = sdk.update(&[keyed1]); + let _ = sdk.update_with_interfaces(&[keyed1]); let specs_after_first = sdk.get_all_specs(); - let _ = sdk.update(&[keyed2]); + let _ = sdk.update_with_interfaces(&[keyed2]); let specs_after_second = sdk.get_all_specs(); // Should have same count (not doubled) @@ -458,7 +460,7 @@ fn test_edge_duplicate_accounts_in_update() { let keyed = keyed_hot(pubkey, data); // Update with same account twice in same call - let _ = sdk.update(&[keyed.clone(), keyed.clone()]); + let _ = sdk.update_with_interfaces(&[keyed.clone(), keyed.clone()]); // Should not have duplicates in specs let specs = sdk.get_all_specs(); @@ -477,21 +479,21 @@ fn test_edge_duplicate_accounts_in_update() { // ============================================================================= #[test] -fn test_get_accounts_to_update_empty() { - // get_accounts_to_update should return empty for uninitialized SDK +fn test_get_accounts_for_instruction_empty() { + // get_accounts_for_instruction should return empty for uninitialized SDK let sdk = AmmSdk::new(); - let typed = sdk.get_accounts_to_update(&AmmInstruction::Swap); + let typed = sdk.get_accounts_for_instruction(AmmInstruction::Swap); assert!(typed.is_empty(), "Typed should be empty before init"); } #[test] -fn test_get_accounts_to_update_categories() { +fn test_get_accounts_for_instruction_categories() { // Verify typed accounts have correct categories use light_client::interface::AccountToFetch; let sdk = AmmSdk::new(); - let typed = sdk.get_accounts_to_update(&AmmInstruction::Deposit); + let typed = sdk.get_accounts_for_instruction(AmmInstruction::Deposit); // All should be one of Pda, Token, Ata, or Mint for acc in &typed { @@ -588,7 +590,7 @@ fn test_specs_contain_all_vaults_not_merged() { let sdk = AmmSdk::new(); // Before init, specs are empty - let specs = sdk.get_specs_for_instruction(&AmmInstruction::Swap); + let specs = sdk.get_specs_for_instruction(AmmInstruction::Swap); // Count of specs should match number of unique accounts // When SDK is properly initialized with pool_state and vaults, @@ -657,11 +659,11 @@ fn test_updating_vault_0_does_not_affect_vault_1() { let vault_1_keyed = keyed_hot(vault_1_pubkey, vault_1_data); // Update with both - let _ = sdk.update(&[vault_0_keyed.clone(), vault_1_keyed.clone()]); + let _ = sdk.update_with_interfaces(&[vault_0_keyed.clone(), vault_1_keyed.clone()]); // Now update vault_0 again with different data let vault_0_updated = keyed_hot(vault_0_pubkey, vec![0xCCu8; 100]); - let _ = sdk.update(&[vault_0_updated]); + let _ = sdk.update_with_interfaces(&[vault_0_updated]); // Verify: vault_1 should still have its original data (if tracked) // The key point: updating by pubkey only affects that specific entry @@ -690,7 +692,7 @@ fn test_operation_returns_all_required_instances() { let sdk = AmmSdk::new(); // Get accounts needed for Swap - let swap_accounts = sdk.get_accounts_to_update(&AmmInstruction::Swap); + let swap_accounts = sdk.get_accounts_for_instruction(AmmInstruction::Swap); // Without pool state, this is empty, but document the contract: // When properly initialized, Swap should request both vaults @@ -700,7 +702,7 @@ fn test_operation_returns_all_required_instances() { // Each vault is a separate entry, not merged // Verify Deposit requests more accounts than Swap - let deposit_accounts = sdk.get_accounts_to_update(&AmmInstruction::Deposit); + let deposit_accounts = sdk.get_accounts_for_instruction(AmmInstruction::Deposit); // Even when empty, the contract holds: // len(deposit_accounts) >= len(swap_accounts) because Deposit is a superset @@ -761,11 +763,11 @@ fn test_swap_returns_both_vaults_regardless_of_role() { let sdk = AmmSdk::new(); - let swap_accounts = sdk.get_accounts_to_update(&AmmInstruction::Swap); + let swap_accounts = sdk.get_accounts_for_instruction(AmmInstruction::Swap); // Without pool state initialized, this is empty, but the contract is: // When pool_state has token_0_vault and token_1_vault set, - // get_accounts_to_update(Swap) returns BOTH. + // get_accounts_for_instruction(Swap) returns BOTH. // // This is because the SDK doesn't know which vault will be "input" vs "output" // at runtime - that depends on trade direction chosen by the user. @@ -833,8 +835,8 @@ fn test_sdk_doesnt_need_trade_direction() { let sdk = AmmSdk::new(); - // Both directions use the same set of accounts from get_accounts_to_update - let accounts = sdk.get_accounts_to_update(&AmmInstruction::Swap); + // Both directions use the same set of accounts from get_accounts_for_instruction + let accounts = sdk.get_accounts_for_instruction(AmmInstruction::Swap); // The SDK's contract: return [token_0_vault, token_1_vault] for Swap // The client then passes them to the instruction as input_vault/output_vault @@ -895,8 +897,8 @@ fn test_swap_and_deposit_share_vault_specs() { let sdk = AmmSdk::new(); - let swap_accounts = sdk.get_accounts_to_update(&AmmInstruction::Swap); - let deposit_accounts = sdk.get_accounts_to_update(&AmmInstruction::Deposit); + let swap_accounts = sdk.get_accounts_for_instruction(AmmInstruction::Swap); + let deposit_accounts = sdk.get_accounts_for_instruction(AmmInstruction::Deposit); // Swap: [token_0_vault, token_1_vault] // Deposit: [token_0_vault, token_1_vault, observation, lp_mint] @@ -937,7 +939,7 @@ fn test_canonical_variant_independent_of_alias() { let sdk = AmmSdk::new(); // Get specs - let specs = sdk.get_specs_for_instruction(&AmmInstruction::Swap); + let specs = sdk.get_specs_for_instruction(AmmInstruction::Swap); // All specs should have canonical variants for spec in &specs { @@ -989,7 +991,7 @@ fn test_swap_loads_decompresses_before_execution() { let sdk = AmmSdk::new(); // Step 1-2: Get accounts - let _accounts = sdk.get_accounts_to_update(&AmmInstruction::Swap); + let _accounts = sdk.get_accounts_for_instruction(AmmInstruction::Swap); // Step 3-4: Decompression (direction-agnostic) // Both vaults decompressed regardless of which is input/output @@ -1054,7 +1056,7 @@ fn test_multiple_operations_same_underlying_account() { #[test] fn test_invariant_get_accounts_subset_of_specs() { - // INVARIANT: For all operations, get_accounts_to_update() pubkeys + // INVARIANT: For all operations, get_accounts_for_instruction() pubkeys // must be a subset of get_specs_for_instruction() addresses. // // This catches bugs where one method was updated but not the other. @@ -1067,12 +1069,12 @@ fn test_invariant_get_accounts_subset_of_specs() { AmmInstruction::Withdraw, ] { let update_keys: HashSet<_> = sdk - .get_accounts_to_update(&op) + .get_accounts_for_instruction(op) .into_iter() .map(|a| a.pubkey()) .collect(); let spec_keys: HashSet<_> = sdk - .get_specs_for_instruction(&op) + .get_specs_for_instruction(op) .iter() .map(|s| s.pubkey()) .collect(); @@ -1080,7 +1082,7 @@ fn test_invariant_get_accounts_subset_of_specs() { // When SDK is empty, both should be empty assert!( update_keys.is_subset(&spec_keys) || (update_keys.is_empty() && spec_keys.is_empty()), - "get_accounts_to_update must return subset of get_specs_for_instruction for {:?}\n update_keys: {:?}\n spec_keys: {:?}", + "get_accounts_for_instruction must return subset of get_specs_for_instruction for {:?}\n update_keys: {:?}\n spec_keys: {:?}", op, update_keys, spec_keys ); } @@ -1088,8 +1090,8 @@ fn test_invariant_get_accounts_subset_of_specs() { #[test] fn test_invariant_typed_matches_untyped_pubkeys() { - // INVARIANT: get_accounts_to_update() must return the same pubkeys - // as get_accounts_to_update(), just with type information. + // INVARIANT: get_accounts_for_instruction() must return the same pubkeys + // when called multiple times (idempotent). // (Now they're the same method, so this test is essentially a no-op) let sdk = AmmSdk::new(); @@ -1100,12 +1102,12 @@ fn test_invariant_typed_matches_untyped_pubkeys() { AmmInstruction::Withdraw, ] { let untyped: HashSet<_> = sdk - .get_accounts_to_update(&op) + .get_accounts_for_instruction(op) .into_iter() .map(|a| a.pubkey()) .collect(); let typed: HashSet<_> = sdk - .get_accounts_to_update(&op) + .get_accounts_for_instruction(op) .iter() .map(|a| a.pubkey()) .collect(); @@ -1120,18 +1122,16 @@ fn test_invariant_typed_matches_untyped_pubkeys() { #[test] fn test_invariant_all_methods_derive_from_account_requirements() { - // DESIGN INVARIANT: All three methods must derive from account_requirements() + // DESIGN INVARIANT: All methods must derive from account_requirements() // - // get_accounts_to_update() -> account_requirements().map(pubkey) - // get_accounts_to_update() -> account_requirements().map(to_fetch) - // get_specs_for_instruction() -> account_requirements().filter_map(spec_lookup) + // get_accounts_for_instruction() -> account_requirements().map(to_fetch) + // get_specs_for_instruction() -> account_requirements().filter_map(spec_lookup) // // This ensures they can NEVER drift out of sync. // Verify by code inspection: - // 1. get_accounts_to_update() calls self.account_requirements(op) - // 2. get_accounts_to_update() calls self.account_requirements(op) - // 3. get_specs_for_instruction() calls self.account_requirements(op) + // 1. get_accounts_for_instruction() calls self.account_requirements(op) + // 2. get_specs_for_instruction() calls self.account_requirements(op) // // All derive from the SAME source. @@ -1143,9 +1143,9 @@ fn test_invariant_all_methods_derive_from_account_requirements() { AmmInstruction::Deposit, AmmInstruction::Withdraw, ] { - let pubkeys = sdk.get_accounts_to_update(&op); - let typed = sdk.get_accounts_to_update(&op); - let specs = sdk.get_specs_for_instruction(&op); + let pubkeys = sdk.get_accounts_for_instruction(op); + let typed = sdk.get_accounts_for_instruction(op); + let specs = sdk.get_specs_for_instruction(op); // All should be empty for uninitialized SDK assert!(pubkeys.is_empty(), "Empty SDK should return no pubkeys"); @@ -1161,8 +1161,8 @@ fn test_invariant_all_methods_derive_from_account_requirements() { fn test_swap_observation_included_after_refactor() { // Regression test: Swap must include observation after the single-source-of-truth refactor. // - // Before fix: get_accounts_to_update(Swap) returned [vault_0, vault_1] - MISSING observation! - // After fix: get_accounts_to_update(Swap) returns [pool_state, vault_0, vault_1, observation] + // Before fix: get_accounts_for_instruction(Swap) returned [vault_0, vault_1] - MISSING observation! + // After fix: get_accounts_for_instruction(Swap) returns [pool_state, vault_0, vault_1, observation] // Create a mock initialized SDK state // We can't fully initialize without real data, but we can verify the count @@ -1170,8 +1170,8 @@ fn test_swap_observation_included_after_refactor() { let sdk = AmmSdk::new(); // For an uninitialized SDK, both return empty - let swap_accounts = sdk.get_accounts_to_update(&AmmInstruction::Swap); - let deposit_accounts = sdk.get_accounts_to_update(&AmmInstruction::Deposit); + let swap_accounts = sdk.get_accounts_for_instruction(AmmInstruction::Swap); + let deposit_accounts = sdk.get_accounts_for_instruction(AmmInstruction::Deposit); // The key invariant: Swap and Deposit should now have the same number of // non-mint accounts when pool_state is set (pool_state, vault_0, vault_1, observation) diff --git a/sdk-tests/csdk-anchor-full-derived-test/Cargo.toml b/sdk-tests/csdk-anchor-full-derived-test/Cargo.toml index a0752c4360..4d4c0cb893 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/Cargo.toml +++ b/sdk-tests/csdk-anchor-full-derived-test/Cargo.toml @@ -60,6 +60,7 @@ solana-keypair = { workspace = true } solana-account = { workspace = true } bincode = "1.3" sha2 = { workspace = true } +jupiter-amm-interface = "0.6" [lints.rust.unexpected_cfgs] level = "allow" diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs index db9f617a8b..7bfe612d15 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/amm_test.rs @@ -7,6 +7,8 @@ /// 4. Advance epochs to trigger auto-compression /// 5. Decompress all accounts /// 6. Deposit after decompression to verify pool works +/// +/// Also includes aggregator-style test flow using LightAmmInterface. mod shared; use anchor_lang::{InstructionData, ToAccountMetas}; @@ -16,9 +18,10 @@ use csdk_anchor_full_derived_test::amm_test::{ }; // SDK for AmmSdk-based approach use csdk_anchor_full_derived_test_sdk::{AmmInstruction, AmmSdk}; +use jupiter_amm_interface::{Amm, AmmContext, KeyedAccount, QuoteParams, SwapMode, SwapParams}; use light_client::interface::{ create_load_instructions, get_create_accounts_proof, AccountInterfaceExt, - InitializeRentFreeConfig, LightProgramInterface, + InitializeRentFreeConfig, LightAmmInterface, LightProgramInterface, }; use light_compressible::rent::SLOTS_PER_EPOCH; use light_macros::pubkey; @@ -560,7 +563,7 @@ async fn test_amm_full_lifecycle() { let mut sdk = AmmSdk::from_keyed_accounts(&[pool_interface]) .expect("ProgrammSdk::from_keyed_accounts should succeed"); - let accounts_to_fetch = sdk.get_accounts_to_update(&AmmInstruction::Deposit); + let accounts_to_fetch = sdk.get_accounts_for_instruction(AmmInstruction::Deposit); let keyed_accounts = ctx .rpc @@ -568,10 +571,10 @@ async fn test_amm_full_lifecycle() { .await .expect("get_multiple_account_interfaces should succeed"); - sdk.update(&keyed_accounts) + sdk.update_with_interfaces(&keyed_accounts) .expect("sdk.update should succeed"); - let specs = sdk.get_specs_for_instruction(&AmmInstruction::Deposit); + let specs = sdk.get_specs_for_instruction(AmmInstruction::Deposit); let creator_lp_interface = ctx .rpc @@ -661,3 +664,458 @@ async fn test_amm_full_lifecycle() { "Compressed creator_lp_token should be consumed" ); } + +/// Aggregator-style test flow demonstrating LightAmmInterface usage. +/// +/// This test simulates how a DEX aggregator (like Jupiter) would: +/// 1. Discover a pool via pool_state account +/// 2. Use get_swap_accounts() to know what to fetch/cache +/// 3. Detect cold accounts via swap_needs_loading() +/// 4. Use get_cold_swap_specs() to build load instructions +/// 5. Execute load + swap atomically +#[tokio::test] +async fn test_aggregator_flow() { + let mut ctx = setup().await; + + let pdas = derive_amm_pdas( + &ctx.program_id, + &ctx.amm_config.pubkey(), + &ctx.token_0_mint, + &ctx.token_1_mint, + &ctx.creator.pubkey(), + ); + + // Initialize pool (same as above) + let proof_inputs = AmmSdk::create_initialize_pool_proof_inputs( + pdas.pool_state, + pdas.observation_state, + pdas.lp_mint, + ); + let proof_result = get_create_accounts_proof(&ctx.rpc, &ctx.program_id, proof_inputs) + .await + .unwrap(); + + let init_params = InitializeParams { + init_amount_0: 1000u64, + init_amount_1: 1000u64, + open_time: 0u64, + create_accounts_proof: proof_result.create_accounts_proof, + lp_mint_signer_bump: pdas.lp_mint_signer_bump, + creator_lp_token_bump: pdas.creator_lp_token_bump, + authority_bump: pdas.authority_bump, + }; + + let accounts = csdk_anchor_full_derived_test::accounts::InitializePool { + creator: ctx.creator.pubkey(), + amm_config: ctx.amm_config.pubkey(), + authority: pdas.authority, + pool_state: pdas.pool_state, + token_0_mint: ctx.token_0_mint, + token_1_mint: ctx.token_1_mint, + lp_mint_signer: pdas.lp_mint_signer, + lp_mint: pdas.lp_mint, + creator_token_0: ctx.creator_token_0, + creator_token_1: ctx.creator_token_1, + creator_lp_token: pdas.creator_lp_token, + token_0_vault: pdas.token_0_vault, + token_1_vault: pdas.token_1_vault, + observation_state: pdas.observation_state, + token_program: LIGHT_TOKEN_PROGRAM_ID, + token_0_program: LIGHT_TOKEN_PROGRAM_ID, + token_1_program: LIGHT_TOKEN_PROGRAM_ID, + associated_token_program: LIGHT_TOKEN_PROGRAM_ID, + system_program: solana_sdk::system_program::ID, + rent: solana_sdk::sysvar::rent::ID, + compression_config: ctx.config_pda, + light_token_compressible_config: COMPRESSIBLE_CONFIG_V1, + rent_sponsor: LIGHT_TOKEN_RENT_SPONSOR, + light_token_program: LIGHT_TOKEN_PROGRAM_ID, + light_token_cpi_authority: LIGHT_TOKEN_CPI_AUTHORITY, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: csdk_anchor_full_derived_test::instruction::InitializePool { + params: init_params, + } + .data(), + }; + + ctx.rpc + .create_and_send_transaction( + &[instruction], + &ctx.payer.pubkey(), + &[&ctx.payer, &ctx.creator], + ) + .await + .expect("Initialize pool should succeed"); + + // ========================================================================== + // AGGREGATOR FLOW: Pool is now hot - simulate aggregator discovering it + // ========================================================================== + + // Step 1: Aggregator discovers pool via pool_state pubkey + let pool_interface = ctx + .rpc + .get_account_interface(&pdas.pool_state, &ctx.program_id) + .await + .expect("failed to get pool_state"); + + // Step 2: Create SDK from pool discovery + let mut sdk = AmmSdk::from_keyed_accounts(&[pool_interface]) + .expect("AmmSdk::from_keyed_accounts should succeed"); + + // Step 3: Use LightAmmInterface methods to get swap-relevant accounts + let swap_accounts = sdk.get_swap_accounts(); + assert!(!swap_accounts.is_empty(), "Swap should require accounts"); + + // Step 4: Fetch all swap accounts + let keyed_accounts = ctx + .rpc + .get_multiple_account_interfaces(&swap_accounts) + .await + .expect("get_multiple_account_interfaces should succeed"); + + sdk.update_with_interfaces(&keyed_accounts) + .expect("sdk.update should succeed"); + + // Step 5: Check if any swap accounts are cold (they're all hot at this point) + assert!( + !sdk.swap_needs_loading(), + "Fresh pool should have all hot accounts" + ); + + // ========================================================================== + // AGGREGATOR FLOW: Accounts go cold - simulate compression + // ========================================================================== + + // Advance epochs to trigger auto-compression + ctx.rpc + .warp_slot_forward(SLOTS_PER_EPOCH * 30) + .await + .unwrap(); + + // Re-fetch pool to check if cold + let pool_interface = ctx + .rpc + .get_account_interface(&pdas.pool_state, &ctx.program_id) + .await + .expect("failed to get pool_state after compression"); + + assert!(pool_interface.is_cold(), "pool_state should be cold now"); + + // Re-initialize SDK with cold pool + let mut sdk = AmmSdk::from_keyed_accounts(&[pool_interface]) + .expect("AmmSdk::from_keyed_accounts should succeed with cold account"); + + // Fetch swap accounts again (now cold) + let swap_accounts = sdk.get_swap_accounts(); + let keyed_accounts = ctx + .rpc + .get_multiple_account_interfaces(&swap_accounts) + .await + .expect("get_multiple_account_interfaces should succeed"); + + sdk.update_with_interfaces(&keyed_accounts) + .expect("sdk.update should succeed"); + + // Step 6: Check cold status using LightAmmInterface + assert!( + sdk.swap_needs_loading(), + "Compressed pool should need loading for swap" + ); + + // Step 7: Get cold specs for swap (lean, no redundant Account data) + let cold_specs = sdk.get_cold_swap_specs(); + assert!(!cold_specs.is_empty(), "Should have cold specs for swap"); + + // Verify cold specs have the expected keys + let cold_keys: std::collections::HashSet<_> = cold_specs.iter().map(|s| s.key()).collect(); + assert!( + cold_keys.contains(&pdas.pool_state), + "Cold specs should include pool_state" + ); + assert!( + cold_keys.contains(&pdas.token_0_vault), + "Cold specs should include token_0_vault" + ); + assert!( + cold_keys.contains(&pdas.token_1_vault), + "Cold specs should include token_1_vault" + ); + assert!( + cold_keys.contains(&pdas.observation_state), + "Cold specs should include observation_key" + ); + + // Step 8: Build load instructions from full specs + let specs = sdk.get_swap_specs(); + let load_ixs = create_load_instructions( + &specs, + ctx.payer.pubkey(), + ctx.config_pda, + ctx.payer.pubkey(), + &ctx.rpc, + ) + .await + .expect("create_load_instructions should succeed"); + + assert!( + !load_ixs.is_empty(), + "Should have load instructions for cold accounts" + ); + + // Step 9: Execute load instructions + ctx.rpc + .create_and_send_transaction(&load_ixs, &ctx.payer.pubkey(), &[&ctx.payer]) + .await + .expect("Load instructions should succeed"); + + // Verify accounts are now on-chain + assert_onchain_exists(&mut ctx.rpc, &pdas.pool_state).await; + assert_onchain_exists(&mut ctx.rpc, &pdas.token_0_vault).await; + assert_onchain_exists(&mut ctx.rpc, &pdas.token_1_vault).await; + assert_onchain_exists(&mut ctx.rpc, &pdas.observation_state).await; + + // Step 10: Re-fetch and verify no longer needs loading + let keyed_accounts = ctx + .rpc + .get_multiple_account_interfaces(&sdk.get_swap_accounts()) + .await + .expect("get_multiple_account_interfaces should succeed"); + + sdk.update_with_interfaces(&keyed_accounts) + .expect("sdk.update should succeed"); + + assert!( + !sdk.swap_needs_loading(), + "After decompression, swap should not need loading" + ); +} + +/// Jupiter Amm trait test - exercises the actual Jupiter interface. +/// +/// This test demonstrates how Jupiter would use the AmmSdk: +/// 1. Discover pool via KeyedAccount +/// 2. Use Amm::from_keyed_account() to create SDK +/// 3. Use Amm::get_accounts_to_update() and Amm::update() +/// 4. Use Amm::quote() to get a swap quote +/// 5. Use Amm::get_swap_and_account_metas() to build swap instruction +#[tokio::test] +async fn test_jupiter_amm_trait() { + let mut ctx = setup().await; + + let pdas = derive_amm_pdas( + &ctx.program_id, + &ctx.amm_config.pubkey(), + &ctx.token_0_mint, + &ctx.token_1_mint, + &ctx.creator.pubkey(), + ); + + // Initialize pool + let proof_inputs = AmmSdk::create_initialize_pool_proof_inputs( + pdas.pool_state, + pdas.observation_state, + pdas.lp_mint, + ); + let proof_result = get_create_accounts_proof(&ctx.rpc, &ctx.program_id, proof_inputs) + .await + .unwrap(); + + let init_params = InitializeParams { + init_amount_0: 10_000u64, + init_amount_1: 10_000u64, + open_time: 0u64, + create_accounts_proof: proof_result.create_accounts_proof, + lp_mint_signer_bump: pdas.lp_mint_signer_bump, + creator_lp_token_bump: pdas.creator_lp_token_bump, + authority_bump: pdas.authority_bump, + }; + + let accounts = csdk_anchor_full_derived_test::accounts::InitializePool { + creator: ctx.creator.pubkey(), + amm_config: ctx.amm_config.pubkey(), + authority: pdas.authority, + pool_state: pdas.pool_state, + token_0_mint: ctx.token_0_mint, + token_1_mint: ctx.token_1_mint, + lp_mint_signer: pdas.lp_mint_signer, + lp_mint: pdas.lp_mint, + creator_token_0: ctx.creator_token_0, + creator_token_1: ctx.creator_token_1, + creator_lp_token: pdas.creator_lp_token, + token_0_vault: pdas.token_0_vault, + token_1_vault: pdas.token_1_vault, + observation_state: pdas.observation_state, + token_program: LIGHT_TOKEN_PROGRAM_ID, + token_0_program: LIGHT_TOKEN_PROGRAM_ID, + token_1_program: LIGHT_TOKEN_PROGRAM_ID, + associated_token_program: LIGHT_TOKEN_PROGRAM_ID, + system_program: solana_sdk::system_program::ID, + rent: solana_sdk::sysvar::rent::ID, + compression_config: ctx.config_pda, + light_token_compressible_config: COMPRESSIBLE_CONFIG_V1, + rent_sponsor: LIGHT_TOKEN_RENT_SPONSOR, + light_token_program: LIGHT_TOKEN_PROGRAM_ID, + light_token_cpi_authority: LIGHT_TOKEN_CPI_AUTHORITY, + }; + + let instruction = Instruction { + program_id: ctx.program_id, + accounts: [ + accounts.to_account_metas(None), + proof_result.remaining_accounts, + ] + .concat(), + data: csdk_anchor_full_derived_test::instruction::InitializePool { + params: init_params, + } + .data(), + }; + + ctx.rpc + .create_and_send_transaction( + &[instruction], + &ctx.payer.pubkey(), + &[&ctx.payer, &ctx.creator], + ) + .await + .expect("Initialize pool should succeed"); + + // ========================================================================== + // JUPITER AMM TRAIT FLOW + // ========================================================================== + + // Step 1: Jupiter discovers pool - fetches pool_state account + let pool_account = ctx + .rpc + .get_account(pdas.pool_state) + .await + .unwrap() + .expect("Pool state should exist"); + + // Step 2: Create KeyedAccount (Jupiter's input format) + let keyed_account = KeyedAccount { + key: pdas.pool_state, + account: pool_account, + params: None, + }; + + // Step 3: Use Amm::from_keyed_account() - Jupiter's entry point + let amm_context = AmmContext { + clock_ref: Default::default(), + }; + let mut amm = AmmSdk::from_keyed_account(&keyed_account, &amm_context) + .expect("Amm::from_keyed_account should succeed"); + + // Verify identity methods + assert_eq!(amm.label(), "LightAMM"); + assert_eq!(amm.program_id(), ctx.program_id); + assert_eq!(amm.key(), pdas.pool_state); + assert!(amm.is_active()); + + // Verify reserve mints + let reserve_mints = amm.get_reserve_mints(); + assert_eq!(reserve_mints.len(), 2); + assert!(reserve_mints.contains(&ctx.token_0_mint)); + assert!(reserve_mints.contains(&ctx.token_1_mint)); + + // Step 4: Get accounts to update (Jupiter calls this) + let accounts_to_update = amm.get_accounts_to_update(); + assert!(!accounts_to_update.is_empty(), "Should have accounts to update"); + + // Step 5: Fetch accounts and update (Jupiter's cache update) + let fetched_accounts = ctx + .rpc + .get_multiple_accounts(&accounts_to_update) + .await + .unwrap(); + + let account_map: jupiter_amm_interface::AccountMap = accounts_to_update + .iter() + .zip(fetched_accounts.iter()) + .filter_map(|(pubkey, opt_account)| { + opt_account.as_ref().map(|account| (*pubkey, account.clone())) + }) + .collect(); + + Amm::update(&mut amm, &account_map).expect("Amm::update should succeed"); + + // Step 6: Get a quote (Jupiter's quoting) + // Note: The test AMM doesn't transfer tokens in initialize, so vaults have 0 balance + // This tests the quote interface works, even with empty pools + let quote_params = QuoteParams { + amount: 100, + input_mint: ctx.token_0_mint, + output_mint: ctx.token_1_mint, + swap_mode: SwapMode::ExactIn, + }; + + let quote = amm.quote("e_params).expect("Amm::quote should succeed"); + + assert_eq!(quote.in_amount, 100, "Input amount should match"); + assert_eq!(quote.fee_mint, ctx.token_0_mint, "Fee mint should be input mint"); + // With 0/0 reserves, output is 0 (empty pool) + assert_eq!(quote.out_amount, 0, "Empty pool should return 0 output"); + + // Step 7: Build swap instruction (Jupiter's swap building) + let jupiter_program_id = solana_pubkey::pubkey!("JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4"); + let swap_params = SwapParams { + swap_mode: SwapMode::ExactIn, + in_amount: quote.in_amount, + out_amount: quote.out_amount, + source_mint: ctx.token_0_mint, + destination_mint: ctx.token_1_mint, + source_token_account: ctx.creator_token_0, + destination_token_account: ctx.creator_token_1, + token_transfer_authority: ctx.creator.pubkey(), + quote_mint_to_referrer: None, + jupiter_program_id: &jupiter_program_id, + missing_dynamic_accounts_as_default: false, + }; + + let swap_result = amm + .get_swap_and_account_metas(&swap_params) + .expect("Amm::get_swap_and_account_metas should succeed"); + + // Verify swap instruction structure + assert!( + !swap_result.account_metas.is_empty(), + "Swap should have account metas" + ); + + // Verify expected accounts are in the metas + let account_keys: Vec = swap_result + .account_metas + .iter() + .map(|m| m.pubkey) + .collect(); + + assert!( + account_keys.contains(&pdas.pool_state), + "Swap accounts should include pool_state" + ); + assert!( + account_keys.contains(&pdas.token_0_vault), + "Swap accounts should include token_0_vault" + ); + assert!( + account_keys.contains(&pdas.token_1_vault), + "Swap accounts should include token_1_vault" + ); + assert!( + account_keys.contains(&pdas.observation_state), + "Swap accounts should include observation_state" + ); + + // Step 8: Verify clone_amm works + let cloned = amm.clone_amm(); + assert_eq!(cloned.key(), pdas.pool_state, "Cloned AMM should have same key"); + assert_eq!(cloned.label(), "LightAMM", "Cloned AMM should have same label"); +} From cac139cced2357138554441cef91dffd0585a20e Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Thu, 29 Jan 2026 16:55:26 +0000 Subject: [PATCH 5/6] lock --- Cargo.lock | 2 +- sdk-libs/client/docs/integration.md | 201 ++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 sdk-libs/client/docs/integration.md diff --git a/Cargo.lock b/Cargo.lock index 0f1585095a..1ae60b5e05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 4 +version = 3 [[package]] name = "Inflector" diff --git a/sdk-libs/client/docs/integration.md b/sdk-libs/client/docs/integration.md new file mode 100644 index 0000000000..00dc711e06 --- /dev/null +++ b/sdk-libs/client/docs/integration.md @@ -0,0 +1,201 @@ +# Aggregator Integration Guide + +Compression support for AMM pools. Minimal changes to existing infrastructure. + +## Architecture + +``` + ┌─────────────────────────────────────────┐ + │ AGGREGATOR │ + │ │ + ┌─────────┐ │ ┌─────────────────────────────────┐ │ + │ Geyser │──────────┼─▶│ Event Handler │ │ + │ Stream │ │ │ │ │ + └─────────┘ │ │ account_update(pubkey, data) │ │ + │ │ │ │ │ + │ │ ▼ │ │ + │ │ ┌───────────────────────┐ │ │ + │ │ │ is_compressible(pk)? │ │ │ + │ │ └───────┬───────┬───────┘ │ │ + │ │ │ │ │ │ + │ │ NO │ │ YES │ │ + │ │ │ │ │ │ + │ │ ▼ ▼ │ │ + │ │ ┌───────────────────────┐ │ │ + │ │ │ is_closure(data)? │ │ │ + │ │ └───────┬───────┬───────┘ │ │ + │ │ │ │ │ │ + │ │ NO │ │ YES │ │ + │ │ ▼ ▼ │ │ + │ └──────────┼───────┼──────────────┘ │ + │ │ │ │ + │ ▼ ▼ │ + ┌─────────┐ │ ┌──────────────┐ ┌──────────────┐ │ + │ Photon │◀─────────┼──│ │ │ │ │ + │ Indexer │──────────┼─▶│ hot_cache │ │ cold_cache │ │ + └─────────┘ │ │ (Account) │ │ (Interface) │ │ + │ └──────┬───────┘ └──────┬───────┘ │ + │ │ │ │ + │ └────────┬────────┘ │ + │ ▼ │ + │ ┌──────────────────┐ │ + │ │ AMM SDK │ │ + │ │ │ │ + │ │ .quote() │ │ + │ │ .swap_needs_ │ │ + │ │ loading() │ │ + │ │ .get_cold_swap_ │ │ + │ │ specs() │ │ + │ └──────────────────┘ │ + └─────────────────────────────────────────┘ +``` + +## Storage Schema + +### PostgreSQL + +```sql +ALTER TABLE pools ADD COLUMN is_compressible BOOLEAN DEFAULT FALSE; +ALTER TABLE pools ADD COLUMN compressible_accounts TEXT[]; -- pubkeys +``` + +### Redis + +``` +# Hot accounts (unchanged) +account:{pubkey} -> Account { lamports, data, owner } + +# Cold accounts (new) +cold:{pubkey} -> AccountInterface { key, account, cold_context } +``` + +## Event Handling + +```rust +fn handle_account_update(pubkey: Pubkey, account: Option) { + let pool = db.get_pool_by_account(pubkey)?; + + if !pool.is_compressible { + // Existing flow - unchanged + match account { + Some(acc) => hot_cache.set(pubkey, acc), + None => hot_cache.delete(pubkey), + } + return; + } + + // Compressible pool + let is_closure = account.map_or(true, |a| a.lamports == 0); + let is_compressible_account = pool.compressible_accounts.contains(&pubkey); + + if is_closure && is_compressible_account { + // Account went cold - fetch from Photon + hot_cache.delete(pubkey); + let interface = photon.get_account_interface(pubkey)?; + cold_cache.set(pubkey, interface); + } else if !is_closure { + // Account is hot (maybe decompressed) + cold_cache.delete(pubkey); + hot_cache.set(pubkey, account.unwrap()); + } +} +``` + +## Quoting + +```rust +fn quote(pool: &Pool, input: u64) -> QuoteResult { + let mut amm = AmmSdk::new(); + + // Load hot accounts + let hot_accounts: Vec<_> = pool.accounts.iter() + .filter_map(|pk| hot_cache.get(pk)) + .map(|acc| AccountInterface::hot(pk, acc)) + .collect(); + + // Load cold accounts + let cold_accounts: Vec<_> = pool.compressible_accounts.iter() + .filter_map(|pk| cold_cache.get(pk)) + .collect(); + + // Update SDK + amm.update_with_interfaces(&hot_accounts)?; + amm.update_with_interfaces(&cold_accounts)?; + + // Quote + let quote = amm.quote(input)?; + + QuoteResult { + output: quote.out_amount, + needs_loading: amm.swap_needs_loading(), + } +} +``` + +## Swap Execution + +```rust +async fn build_swap_tx(pool: &Pool, params: SwapParams, indexer: &Indexer) -> Transaction { + let amm = load_amm(pool); // as above + + let mut instructions = vec![]; + + // Prepend load instructions if needed + if amm.swap_needs_loading() { + let specs = amm.get_swap_specs(); // includes both hot and cold + let load_ixs = create_load_instructions( + &specs, + fee_payer, + compression_config, + rent_sponsor, + indexer, + ).await?; // internally filters for cold only + instructions.extend(load_ixs); + } + + // Build swap instruction + let swap_ix = amm.get_swap_and_account_metas(¶ms)?; + instructions.push(swap_ix); + + Transaction::new(&instructions, payer) +} +``` + +## SDK Methods Reference + +| Method | Purpose | +| --------------------------------------------- | ----------------------------------------------------- | +| `update_with_interfaces(&[AccountInterface])` | Update SDK cache with hot/cold accounts | +| `swap_needs_loading() -> bool` | Check if cold accounts need decompression | +| `get_swap_specs() -> Vec` | Get all swap account specs (hot + cold) | +| `get_compressible_accounts() -> Vec` | Get all compressible account pubkeys | +| `create_load_instructions(specs, ...)` | Build load instructions (filters for cold internally) | + +## Detection: Is Account Compressible? + +On pool discovery, call: + +```rust +let compressible = amm.get_compressible_accounts(); +db.update_pool(pool_id, compressible_accounts: compressible); +``` + +## Photon API + +```rust +// Returns AccountInterface for both hot and cold accounts +photon.get_account_interface(pubkey) -> AccountInterface { + key: Pubkey, + account: Account, // reconstructed data + cold: Option, // None if hot, Some if cold +} +``` + +## Checklist + +- [ ] Add `is_compressible` and `compressible_accounts` columns to pools table +- [ ] Add cold cache (Redis or in-memory) +- [ ] Modify Geyser handler to detect closures on compressible accounts +- [ ] Integrate Photon client for cold account fetches +- [ ] Update quote flow to merge hot + cold accounts +- [ ] Update swap builder to prepend load instructions when needed From e49fd305b63b836c69887f7ae9126514da4042b7 Mon Sep 17 00:00:00 2001 From: Swenschaeferjohann Date: Fri, 30 Jan 2026 13:43:38 +0000 Subject: [PATCH 6/6] spl_interface with options in transfer-interface --- .../src/instruction/transfer_interface.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/sdk-libs/token-sdk/src/instruction/transfer_interface.rs b/sdk-libs/token-sdk/src/instruction/transfer_interface.rs index dabe1029a9..f00c3defa1 100644 --- a/sdk-libs/token-sdk/src/instruction/transfer_interface.rs +++ b/sdk-libs/token-sdk/src/instruction/transfer_interface.rs @@ -323,6 +323,30 @@ impl<'info> TransferInterfaceCpi<'info> { Ok(self) } + /// Attach optional SPL interface accounts for SPL<->Light transfers. + /// + /// Always pass mint and token_program (caller always has these). + /// Pass pda/bump as Option - only needed when one side is SPL. + /// - If pda/bump are `None`: Light-to-Light transfer (no-op). + /// - If pda/bump are `Some`: attaches SPL interface for SPL<->Light transfers. + pub fn spl_interface( + mut self, + mint: AccountInfo<'info>, + spl_token_program: AccountInfo<'info>, + spl_interface_pda: Option>, + spl_interface_pda_bump: Option, + ) -> Self { + if let (Some(pda), Some(bump)) = (spl_interface_pda, spl_interface_pda_bump) { + self.spl_interface = Some(SplInterfaceCpi { + mint, + spl_token_program, + spl_interface_pda: pda, + spl_interface_pda_bump: bump, + }); + } + self + } + /// Build instruction from CPI context pub fn instruction(&self) -> Result { TransferInterface::from(self).instruction()