Skip to main content

fusionamm_sdk/
position.rs

1//
2// Copyright (c) Cryptic Dot
3//
4// Modification based on Orca Whirlpools (https://github.com/orca-so/whirlpools),
5// originally licensed under the Apache License, Version 2.0, prior to February 26, 2025.
6//
7// Modifications licensed under FusionAMM SDK Source-Available License v1.0
8// See the LICENSE file in the project root for license information.
9//
10
11use fusionamm_client::{
12    fetch_all_position_with_filter, get_bundled_position_address, get_position_address, get_position_bundle_address, DecodedAccount, Position,
13    PositionBundle, PositionFilter,
14};
15use fusionamm_core::POSITION_BUNDLE_SIZE;
16use solana_account::Account;
17use solana_client::{nonblocking::rpc_client::RpcClient, rpc_request::TokenAccountsFilter};
18use solana_pubkey::Pubkey;
19use std::{collections::HashMap, error::Error};
20
21use crate::{get_token_accounts_for_owner, ParsedTokenAccount};
22
23/// Represents a single Position account.
24///
25/// This struct contains the address of the position NFT, its decoded data, and the token program
26/// associated with the position NFT, which can be either the standard SPL Token Program or
27/// the Token 2022 Program.
28#[derive(Debug)]
29pub struct HydratedPosition {
30    /// The public key of the Position account.
31    pub address: Pubkey,
32
33    /// The decoded `Position` account data.
34    pub data: Position,
35
36    /// The public key of the token program associated with the position NFT (either SPL Token or Token 2022).
37    pub token_program: Pubkey,
38}
39
40/// Represents a single bundled position within a `PositionBundle` account.
41///
42/// A bundled position is part of a larger `PositionBundle` and contains its own
43/// address and decoded position data.
44#[derive(Debug)]
45pub struct HydratedBundledPosition {
46    /// The public key of the bundled position.
47    pub address: Pubkey,
48
49    /// The decoded `Position` account data for the bundled position.
50    pub data: Position,
51}
52
53/// Represents a Position Bundle account, which includes multiple bundled positions.
54///
55/// This struct contains the address and decoded data of the `PositionBundle` account,
56/// along with the individual bundled positions and the associated token program.
57#[derive(Debug)]
58pub struct HydratedPositionBundle {
59    /// The public key of the Position Bundle account.
60    pub address: Pubkey,
61
62    /// The decoded `PositionBundle` account data.
63    pub data: PositionBundle,
64
65    /// A vector of `HydratedBundledPosition` objects representing the bundled positions represented by the position NFT.
66    pub positions: Vec<HydratedBundledPosition>,
67
68    /// The public key of the token program associated with the position bundle NFT (either SPL Token or Token 2022).
69    pub token_program: Pubkey,
70}
71
72/// Represents either a standalone Position account or a Position Bundle account.
73///
74/// This enum distinguishes between a single `HydratedPosition` and a `HydratedPositionBundle`,
75/// providing a unified type for handling both cases.
76#[derive(Debug)]
77pub enum PositionOrBundle {
78    /// A standalone `HydratedPosition`.
79    Position(HydratedPosition),
80
81    /// A `HydratedPositionBundle` containing multiple bundled positions.
82    PositionBundle(HydratedPositionBundle),
83}
84
85fn get_position_in_bundle_addresses(position_bundle: &PositionBundle) -> Vec<Pubkey> {
86    let mut positions: Vec<Pubkey> = Vec::new();
87    for i in 0..POSITION_BUNDLE_SIZE {
88        let byte_index = i / 8;
89        let bit_index = i % 8;
90        if position_bundle.position_bitmap[byte_index] & (1 << bit_index) != 0 {
91            let result = get_bundled_position_address(&position_bundle.position_bundle_mint, i as u8);
92            if let Ok(result) = result {
93                positions.push(result.0);
94            }
95        }
96    }
97    positions
98}
99
100/// Fetches all positions owned by a given wallet in the FusionAMM pools.
101///
102/// This function retrieves token accounts owned by the wallet, using both the SPL Token Program
103/// and Token 2022 Program. It identifies accounts holding exactly one token, which represent
104/// either a position or a position bundle. For each of these accounts, it fetches the corresponding
105/// position or bundle data, including any bundled positions, and returns them.
106///
107/// # Arguments
108///
109/// * `rpc` - A reference to the Solana RPC client.
110/// * `owner` - The public key of the wallet whose positions should be fetched.
111///
112/// # Returns
113///
114/// A `Result` containing a vector of `PositionOrBundle` objects, representing the decoded
115/// positions or position bundles owned by the given wallet.
116///
117/// # Errors
118///
119/// This function will return an error if:
120/// - Token accounts cannot be fetched.
121/// - Position or position bundle addresses cannot be derived.
122/// - RPC calls fail when fetching account data.
123///
124/// # Example
125/// ```rust
126/// use fusionamm_sdk::fetch_positions_for_owner;
127/// use solana_client::nonblocking::rpc_client::RpcClient;
128/// use solana_keypair::Keypair;
129/// use solana_pubkey::pubkey;
130/// use solana_signer::Signer;
131///
132/// #[tokio::main]
133/// async fn main() {
134///     let rpc = RpcClient::new("https://api.devnet.solana.com".to_string());
135///     let owner = pubkey!("FTEV6CnregJCqU8s8hGR3VAYCrPKHfekXLsJaKHbPBxp");
136///
137///     let positions = fetch_positions_for_owner(&rpc, owner)
138///         .await
139///         .unwrap();
140///
141///     println!("Positions: {:?}", positions);
142/// }
143/// ```
144pub async fn fetch_positions_for_owner(rpc: &RpcClient, owner: Pubkey) -> Result<Vec<PositionOrBundle>, Box<dyn Error>> {
145    let token_accounts = get_token_accounts_for_owner(rpc, owner, TokenAccountsFilter::ProgramId(spl_token::ID)).await?;
146    let token_extension_accounts = get_token_accounts_for_owner(rpc, owner, TokenAccountsFilter::ProgramId(spl_token_2022::ID)).await?;
147
148    let potiential_tokens: Vec<ParsedTokenAccount> = [token_accounts, token_extension_accounts]
149        .into_iter()
150        .flatten()
151        .filter(|x| x.amount == 1)
152        .collect();
153
154    let position_addresses: Vec<Pubkey> = potiential_tokens
155        .iter()
156        .map(|x| get_position_address(&x.mint).map(|x| x.0))
157        .collect::<Result<Vec<Pubkey>, _>>()?;
158
159    let position_bundle_addresses: Vec<Pubkey> = potiential_tokens
160        .iter()
161        .map(|x| get_position_bundle_address(&x.mint).map(|x| x.0))
162        .collect::<Result<Vec<Pubkey>, _>>()?;
163
164    let position_infos = rpc.get_multiple_accounts(&position_addresses).await?;
165
166    let positions: Vec<Option<Position>> = position_infos
167        .iter()
168        .map(|x| x.as_ref().and_then(|x| Position::from_bytes(&x.data).ok()))
169        .collect();
170
171    let position_bundle_infos = rpc.get_multiple_accounts(&position_bundle_addresses).await?;
172
173    let position_bundles: Vec<Option<PositionBundle>> = position_bundle_infos
174        .iter()
175        .map(|x| x.as_ref().and_then(|x| PositionBundle::from_bytes(&x.data).ok()))
176        .collect();
177
178    let bundled_positions_addresses: Vec<Pubkey> = position_bundles.iter().flatten().flat_map(get_position_in_bundle_addresses).collect();
179
180    let bundled_positions_infos: Vec<Account> = rpc
181        .get_multiple_accounts(&bundled_positions_addresses)
182        .await?
183        .into_iter()
184        .flatten()
185        .collect();
186
187    let mut bundled_positions_map: HashMap<Pubkey, Vec<(Pubkey, Position)>> = HashMap::new();
188    for i in 0..bundled_positions_addresses.len() {
189        let bundled_position_address = bundled_positions_addresses[i];
190        let bundled_position_info = &bundled_positions_infos[i];
191        let position = Position::from_bytes(&bundled_position_info.data)?;
192        let key = position.position_mint;
193        bundled_positions_map.entry(key).or_default();
194        if let Some(x) = bundled_positions_map.get_mut(&key) {
195            x.push((bundled_position_address, position))
196        }
197    }
198
199    let mut position_or_bundles: Vec<PositionOrBundle> = Vec::new();
200
201    for i in 0..potiential_tokens.len() {
202        let position = &positions[i];
203        let position_bundle = &position_bundles[i];
204        let token_account = &potiential_tokens[i];
205
206        if let Some(position) = position {
207            let position_address = position_addresses[i];
208            position_or_bundles.push(PositionOrBundle::Position(HydratedPosition {
209                address: position_address,
210                data: position.clone(),
211                token_program: token_account.token_program,
212            }));
213        }
214
215        if let Some(position_bundle) = position_bundle {
216            let position_bundle_address = position_bundle_addresses[i];
217            let positions = bundled_positions_map
218                .get(&position_bundle.position_bundle_mint)
219                .unwrap_or(&Vec::new())
220                .iter()
221                .map(|x| HydratedBundledPosition {
222                    address: x.0,
223                    data: x.1.clone(),
224                })
225                .collect();
226            position_or_bundles.push(PositionOrBundle::PositionBundle(HydratedPositionBundle {
227                address: position_bundle_address,
228                data: position_bundle.clone(),
229                positions,
230                token_program: token_account.token_program,
231            }));
232        }
233    }
234
235    Ok(position_or_bundles)
236}
237
238/// Fetches all positions associated with a specific FusionPool.
239///
240/// This function retrieves all positions linked to the given FusionPool address using
241/// program filters. The positions are decoded and returned as a vector of hydrated position objects.
242///
243/// # Arguments
244///
245/// * `rpc` - A reference to the Solana RPC client.
246/// * `fusion_pool` - The public key of the FusionPool whose positions should be fetched.
247///
248/// # Returns
249///
250/// A `Result` containing a vector of `DecodedAccount<Position>` objects, representing the
251/// positions associated with the given FusionPool.
252///
253/// # Errors
254///
255/// This function will return an error if:
256/// - RPC calls fail while fetching filtered accounts.
257/// - Decoding the position data fails.
258///
259/// # Example
260///
261/// ```rust
262/// use fusionamm_sdk::{
263///     fetch_positions_in_fusion_pool,
264/// };
265/// use solana_client::nonblocking::rpc_client::RpcClient;
266/// use solana_keypair::Keypair;
267/// use solana_pubkey::pubkey;
268/// use solana_signer::Signer;
269///
270/// #[tokio::main]
271/// async fn main() {
272///     let rpc = RpcClient::new("https://api.devnet.solana.com".to_string());
273///     let fusion_pool_address = pubkey!("3KBZiL2g8C7tiJ32hTv5v3KM7aK9htpqTw4cTXz1HvPt");
274///
275///     let positions = fetch_positions_in_fusion_pool(&rpc, fusion_pool_address)
276///         .await
277///         .unwrap();
278///
279///     println!("Positions: {:?}", positions);
280/// }
281/// ```
282pub async fn fetch_positions_in_fusion_pool(rpc: &RpcClient, fusion_pool: Pubkey) -> Result<Vec<DecodedAccount<Position>>, Box<dyn Error>> {
283    let filters = vec![PositionFilter::FusionPool(fusion_pool)];
284    fetch_all_position_with_filter(rpc, filters).await
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use crate::tests::{setup_ata_with_amount, setup_fusion_pool, setup_mint_with_decimals, setup_position, setup_position_bundle, RpcContext};
291    use serial_test::serial;
292    use solana_program_test::tokio;
293    use solana_signer::Signer;
294    use std::error::Error;
295
296    #[tokio::test]
297    #[serial]
298    #[ignore = "Skipped until solana-bankrun supports gpa"]
299    async fn test_fetch_positions_for_owner_no_positions() -> Result<(), Box<dyn Error>> {
300        let ctx = RpcContext::new().await;
301        let owner = ctx.signer.pubkey();
302        let positions = fetch_positions_for_owner(&ctx.rpc, owner).await?;
303        assert!(positions.is_empty(), "No positions should exist for a new owner");
304        Ok(())
305    }
306
307    #[tokio::test]
308    #[serial]
309    #[ignore = "Skipped until solana-bankrun supports gpa"]
310    async fn test_fetch_positions_for_owner_with_position() -> Result<(), Box<dyn Error>> {
311        let ctx = RpcContext::new().await;
312        let mint_a = setup_mint_with_decimals(&ctx, 9).await?;
313        let mint_b = setup_mint_with_decimals(&ctx, 9).await?;
314        setup_ata_with_amount(&ctx, mint_a, 1_000_000_000).await?;
315        setup_ata_with_amount(&ctx, mint_b, 1_000_000_000).await?;
316
317        let fusion_pool = setup_fusion_pool(&ctx, mint_a, mint_b, 64, 300).await?;
318
319        // 1) Add a te_position (uses token-2022)
320        let te_position_pubkey = setup_position(&ctx, fusion_pool, None, None).await?;
321
322        // 2) Add a position bundle, optionally with multiple bundled positions
323        let _position_bundle_pubkey = setup_position_bundle(fusion_pool, Some(vec![(), ()])).await?;
324
325        let owner = ctx.signer.pubkey();
326        let positions = fetch_positions_for_owner(&ctx.rpc, owner).await?;
327
328        // Expect at least 3: te_position, and a bundle
329        assert!(positions.len() >= 2, "Did not find all positions for the owner (expected normal, te_position, bundle)");
330
331        // Existing checks remain...
332        match &positions[0] {
333            PositionOrBundle::Position(pos) => {
334                assert_eq!(pos.address, te_position_pubkey);
335            }
336            _ => panic!("Expected a single position, but found a bundle!"),
337        }
338
339        Ok(())
340    }
341
342    #[tokio::test]
343    #[serial]
344    #[ignore = "Skipped until solana-bankrun supports gpa"]
345    async fn test_fetch_positions_in_fusion_pool() -> Result<(), Box<dyn Error>> {
346        let ctx = RpcContext::new().await;
347        let mint_a = setup_mint_with_decimals(&ctx, 9).await?;
348        let mint_b = setup_mint_with_decimals(&ctx, 9).await?;
349        setup_ata_with_amount(&ctx, mint_a, 1_000_000_000).await?;
350        setup_ata_with_amount(&ctx, mint_b, 1_000_000_000).await?;
351
352        let fusion_pool = setup_fusion_pool(&ctx, mint_a, mint_b, 64, 300).await?;
353
354        // 1) te_position
355        let _te_position_pubkey = setup_position(&ctx, fusion_pool, None, None).await?;
356
357        // 2) position bundle
358        let _position_bundle_pubkey = setup_position_bundle(fusion_pool, Some(vec![(), ()])).await?;
359
360        let positions = fetch_positions_in_fusion_pool(&ctx.rpc, fusion_pool).await?;
361
362        // Expect at least 2: te_position + bundle
363        assert!(positions.len() >= 2, "Should find multiple positions in this fusion_pool, including te_position & bundle");
364
365        Ok(())
366    }
367}