Skip to main content

acta_maker_sdk/chain/
rpc.rs

1use solana_commitment_config::CommitmentConfig;
2use solana_rpc_client::nonblocking::rpc_client::RpcClient;
3use solana_sdk::{
4    account::Account, instruction::Instruction, message::AddressLookupTableAccount, pubkey::Pubkey,
5    signature::Signature, signer::Signer, transaction::Transaction,
6};
7
8use crate::chain::ChainError;
9use crate::chain::accounts::{GlobalConfigInfo, MarketInfo, PositionInfo};
10use crate::chain::ix::{
11    DepositPremiumIxArgs, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, WithdrawPremiumIxArgs,
12    build_deposit_premium_ixs, build_withdraw_premium_ixs,
13};
14use crate::chain::settlement::required_settlement;
15use crate::chain::wsol::{
16    NativeSolBudget, NativeSolFunding, WrapPlan, WsolWrapRequest, is_native_mint, plan_wsol_wrap,
17    token_account_rent_lamports,
18};
19
20const ADDRESS_LOOKUP_TABLE_PROGRAM_ID: Pubkey =
21    solana_sdk::pubkey!("AddressLookupTab1e1111111111111111111111111");
22const LOOKUP_TABLE_META_LEN: usize = 56;
23
24fn parse_lookup_table(
25    key: Pubkey,
26    account: &Account,
27) -> Result<AddressLookupTableAccount, ChainError> {
28    if account.owner != ADDRESS_LOOKUP_TABLE_PROGRAM_ID {
29        return Err(ChainError::AccountOwnerMismatch {
30            expected: ADDRESS_LOOKUP_TABLE_PROGRAM_ID,
31            actual: account.owner,
32        });
33    }
34    let data = &account.data;
35    let (Some(kind), Some(deactivation), Some(addresses)) = (
36        data.get(..4),
37        data.get(4..12),
38        data.get(LOOKUP_TABLE_META_LEN..),
39    ) else {
40        return Err(ChainError::InvalidAccountData);
41    };
42    if kind != 1u32.to_le_bytes() || addresses.len() % 32 != 0 {
43        return Err(ChainError::InvalidAccountData);
44    }
45    if deactivation != u64::MAX.to_le_bytes() {
46        return Err(ChainError::LookupTableDeactivated(key));
47    }
48    Ok(AddressLookupTableAccount {
49        key,
50        addresses: addresses
51            .as_chunks::<32>()
52            .0
53            .iter()
54            .map(|bytes| Pubkey::new_from_array(*bytes))
55            .collect(),
56    })
57}
58
59pub struct ChainClient {
60    rpc: RpcClient,
61    program_id: Pubkey,
62    commitment: CommitmentConfig,
63    #[cfg(feature = "ws-client")]
64    confirmation_ws_url: Option<String>,
65}
66
67#[derive(Debug, Clone, Copy, Default)]
68pub struct SendOptions {
69    pub compute_unit_limit: Option<u32>,
70}
71
72#[derive(Debug, Clone)]
73pub struct DepositPremiumArgs {
74    pub maker_owner: Pubkey,
75    pub amount: u64,
76    pub premium_mint: Pubkey,
77    pub token_program: Option<Pubkey>,
78    pub create_atas: bool,
79}
80
81#[derive(Debug, Clone)]
82pub struct WithdrawPremiumArgs {
83    pub maker_owner: Pubkey,
84    pub amount: u64,
85    pub premium_mint: Pubkey,
86    pub token_program: Option<Pubkey>,
87    pub create_atas: bool,
88}
89
90#[derive(Debug, Clone)]
91pub struct FundPositionArgs {
92    pub maker_owner: Pubkey,
93    pub position_pda: Pubkey,
94    pub create_atas: bool,
95    pub native_sol: NativeSolFunding,
96}
97
98struct PreparedFundPosition {
99    instructions: Vec<Instruction>,
100    native_budget: Option<NativeSolBudget>,
101}
102
103impl ChainClient {
104    pub fn rpc(&self) -> &RpcClient {
105        &self.rpc
106    }
107
108    pub fn from_rpc(rpc: RpcClient, program_id: Pubkey) -> Self {
109        let commitment = rpc.commitment();
110        Self {
111            rpc,
112            program_id,
113            commitment,
114            #[cfg(feature = "ws-client")]
115            confirmation_ws_url: None,
116        }
117    }
118
119    /// Use Solana signature subscriptions for confirming conveniences instead of
120    /// the legacy RPC confirmation fallback. Batch submission remains submit-only.
121    #[cfg(feature = "ws-client")]
122    pub fn with_confirmation_ws_url(mut self, url: impl Into<String>) -> Self {
123        self.confirmation_ws_url = Some(url.into());
124        self
125    }
126
127    pub fn program_id(&self) -> Pubkey {
128        self.program_id
129    }
130
131    pub fn new(rpc_url: &str, program_id: Pubkey, commitment: CommitmentConfig) -> Self {
132        let rpc = RpcClient::new_with_commitment(rpc_url.to_string(), commitment);
133        Self {
134            rpc,
135            program_id,
136            commitment,
137            #[cfg(feature = "ws-client")]
138            confirmation_ws_url: None,
139        }
140    }
141
142    /// Submit and wait for confirmation at the client commitment; execution failure is an error.
143    pub async fn deposit_premium(
144        &self,
145        args: DepositPremiumArgs,
146        maker_owner: &(dyn Signer + Sync),
147        fee_payer: Option<&(dyn Signer + Sync)>,
148    ) -> Result<Signature, ChainError> {
149        let payer = fee_payer.map_or(maker_owner, |payer| payer);
150        let ixs = self
151            .build_deposit_premium_ixs(&args, payer.pubkey())
152            .await?;
153        self.send_instructions_with_options(ixs, payer, &[maker_owner], SendOptions::default())
154            .await
155    }
156
157    /// Submit and wait for confirmation at the client commitment; execution failure is an error.
158    pub async fn withdraw_premium(
159        &self,
160        args: WithdrawPremiumArgs,
161        maker_owner: &(dyn Signer + Sync),
162        fee_payer: Option<&(dyn Signer + Sync)>,
163    ) -> Result<Signature, ChainError> {
164        let payer = fee_payer.map_or(maker_owner, |payer| payer);
165        let ixs = self
166            .build_withdraw_premium_ixs(&args, payer.pubkey())
167            .await?;
168        self.send_instructions_with_options(ixs, payer, &[maker_owner], SendOptions::default())
169            .await
170    }
171
172    /// Submit and wait for confirmation at the client commitment; execution failure is an error.
173    pub async fn fund_position(
174        &self,
175        args: FundPositionArgs,
176        maker_owner: &(dyn Signer + Sync),
177        fee_payer: Option<&(dyn Signer + Sync)>,
178    ) -> Result<Signature, ChainError> {
179        let payer = fee_payer.map_or(maker_owner, |payer| payer);
180        let prepared = self.prepare_fund_position(&args, payer.pubkey()).await?;
181        let (signature, blockhash) = self
182            .submit_instructions(
183                prepared.instructions,
184                payer,
185                &[maker_owner],
186                SendOptions::default(),
187                prepared.native_budget,
188                None,
189            )
190            .await?;
191        self.confirm_submitted(signature, blockhash).await
192    }
193
194    pub async fn build_deposit_premium_ixs(
195        &self,
196        args: &DepositPremiumArgs,
197        fee_payer: Pubkey,
198    ) -> Result<Vec<Instruction>, ChainError> {
199        let token_program = self
200            .resolve_token_program(&args.premium_mint, args.token_program)
201            .await?;
202        build_deposit_premium_ixs(
203            &self.program_id,
204            &DepositPremiumIxArgs {
205                maker_owner: args.maker_owner,
206                amount: args.amount,
207                premium_mint: args.premium_mint,
208                token_program,
209                create_atas: args.create_atas,
210            },
211            fee_payer,
212        )
213        .map_err(ChainError::from)
214    }
215
216    pub async fn build_withdraw_premium_ixs(
217        &self,
218        args: &WithdrawPremiumArgs,
219        fee_payer: Pubkey,
220    ) -> Result<Vec<Instruction>, ChainError> {
221        let token_program = self
222            .resolve_token_program(&args.premium_mint, args.token_program)
223            .await?;
224        build_withdraw_premium_ixs(
225            &self.program_id,
226            &WithdrawPremiumIxArgs {
227                maker_owner: args.maker_owner,
228                amount: args.amount,
229                premium_mint: args.premium_mint,
230                token_program,
231                create_atas: args.create_atas,
232            },
233            fee_payer,
234        )
235        .map_err(ChainError::from)
236    }
237
238    pub async fn build_fund_position_ixs(
239        &self,
240        args: &FundPositionArgs,
241        fee_payer: Pubkey,
242    ) -> Result<Vec<Instruction>, ChainError> {
243        Ok(self
244            .prepare_fund_position(args, fee_payer)
245            .await?
246            .instructions)
247    }
248
249    async fn prepare_fund_position(
250        &self,
251        args: &FundPositionArgs,
252        fee_payer: Pubkey,
253    ) -> Result<PreparedFundPosition, ChainError> {
254        let position_data = self.fetch_account(&args.position_pda).await?;
255        let pos = PositionInfo::from_account(&position_data, &self.program_id)?;
256        let market_data = self.fetch_account(&pos.market_pda).await?;
257        let market = MarketInfo::from_account(&market_data, &self.program_id)?;
258
259        let snapshot = super::batch::PositionSnapshot {
260            address: args.position_pda,
261            market_address: pos.market_pda,
262            position: pos.clone(),
263            market: market.clone(),
264        };
265        let mut fund_ixs = snapshot.fund_instructions(
266            &self.program_id,
267            args.maker_owner,
268            fee_payer,
269            args.create_atas,
270        )?;
271
272        let NativeSolFunding::WrapIfNeeded { reserve_lamports } = args.native_sol else {
273            return Ok(PreparedFundPosition {
274                instructions: fund_ixs,
275                native_budget: None,
276            });
277        };
278        let (settlement_mint, settlement_program) = match pos.terms.position_type() {
279            crate::types::PositionType::CoveredCall => {
280                (market.quote_mint, market.quote_token_program_id)
281            }
282            crate::types::PositionType::CashSecuredPut => {
283                (market.underlying_mint, market.underlying_token_program_id)
284            }
285            _ => return Err(ChainError::InvalidSettlementState),
286        };
287        if !is_native_mint(&settlement_mint) {
288            return Ok(PreparedFundPosition {
289                instructions: fund_ixs,
290                native_budget: None,
291            });
292        }
293        let expected_settlement = required_settlement(
294            pos.terms.to_chain().position_type().into(),
295            pos.terms.strike().value(),
296            pos.quantity.value(),
297            market.quote_decimals.value(),
298            market.underlying_decimals.value(),
299        )?;
300        if expected_settlement == 0 {
301            return Err(ChainError::InvalidAccountData);
302        }
303        if settlement_program != crate::chain::wsol::TOKEN_PROGRAM_ID {
304            return Err(ChainError::InvalidNativeTokenProgram(settlement_program));
305        }
306        let funding_ata = crate::chain::ix::derive_associated_token_address(
307            &args.maker_owner,
308            &settlement_mint,
309            &settlement_program,
310        );
311        let position_funding_ata = crate::chain::ix::derive_associated_token_address(
312            &args.position_pda,
313            &settlement_mint,
314            &settlement_program,
315        );
316        let (funding_account, additional_ata_rent_lamports) =
317            if args.create_atas && fee_payer == args.maker_owner {
318                let [position_account, funding_account]: [Option<Account>; 2] = self
319                    .rpc
320                    .get_multiple_accounts_with_commitment(
321                        &[position_funding_ata, funding_ata],
322                        self.commitment,
323                    )
324                    .await?
325                    .value
326                    .try_into()
327                    .map_err(|_| ChainError::InvalidAccountData)?;
328                let rent = if position_account.is_none() {
329                    token_account_rent_lamports(&self.rpc).await?
330                } else {
331                    0
332                };
333                (funding_account, rent)
334            } else {
335                (
336                    self.rpc
337                        .get_account_with_commitment(&funding_ata, self.commitment)
338                        .await?
339                        .value,
340                    0,
341                )
342            };
343        match plan_wsol_wrap(
344            &self.rpc,
345            funding_account,
346            WsolWrapRequest {
347                owner: args.maker_owner,
348                funding_ata,
349                token_program: settlement_program,
350                expected_settlement,
351                reserve_lamports,
352                allow_account_creation: args.create_atas,
353                fee_payer,
354                additional_ata_rent_lamports,
355            },
356        )
357        .await?
358        {
359            WrapPlan::NotNeeded => Ok(PreparedFundPosition {
360                instructions: fund_ixs,
361                native_budget: None,
362            }),
363            WrapPlan::Wrap {
364                instructions: wrap_ixs,
365                budget,
366            } => {
367                let deposit_ix = fund_ixs.pop().ok_or(ChainError::InvalidAccountData)?;
368                fund_ixs.extend(wrap_ixs);
369                fund_ixs.push(deposit_ix);
370                Ok(PreparedFundPosition {
371                    instructions: fund_ixs,
372                    native_budget: Some(budget),
373                })
374            }
375        }
376    }
377
378    pub async fn fetch_position_info(
379        &self,
380        position_pda: &Pubkey,
381    ) -> Result<PositionInfo, ChainError> {
382        let data = self.fetch_account(position_pda).await?;
383        PositionInfo::from_account(&data, &self.program_id)
384    }
385
386    pub async fn fetch_market_info(&self, market_pda: &Pubkey) -> Result<MarketInfo, ChainError> {
387        let market_data = self.fetch_account(market_pda).await?;
388        MarketInfo::from_account(&market_data, &self.program_id)
389    }
390
391    pub async fn fetch_market_quote_mint(&self, market_pda: &Pubkey) -> Result<Pubkey, ChainError> {
392        Ok(self.fetch_market_info(market_pda).await?.quote_mint)
393    }
394
395    /// Reads the canonical GlobalConfig PDA and returns its checked hot authority.
396    ///
397    /// Vault opens use this key as both transaction fee payer and account 0, so
398    /// accepting a venue-provided value would let the venue choose the signer.
399    pub async fn hot_authority(&self) -> Result<Pubkey, ChainError> {
400        let (global_config, bump) =
401            Pubkey::find_program_address(&[b"global_config"], &self.program_id);
402        let account = self.fetch_account(&global_config).await?;
403        let config = GlobalConfigInfo::from_account(&account, &self.program_id)?;
404        if config.bump != bump {
405            return Err(ChainError::Identity("global config PDA"));
406        }
407        Ok(config.hot_authority)
408    }
409
410    /// Fetch the raw token balance (u64 atoms) of a token account.
411    pub async fn fetch_token_balance(&self, token_account: &Pubkey) -> Result<u64, ChainError> {
412        let result = self.rpc.get_token_account_balance(token_account).await?;
413        result
414            .amount
415            .parse::<u64>()
416            .map_err(|_| ChainError::InvalidAccountData)
417    }
418
419    /// Active lookup tables by key, for verifying served v0 transactions.
420    pub async fn address_lookup_tables(
421        &self,
422        keys: &[Pubkey],
423    ) -> Result<Vec<AddressLookupTableAccount>, ChainError> {
424        let accounts = self
425            .rpc
426            .get_multiple_accounts_with_commitment(keys, self.commitment)
427            .await?
428            .value;
429        keys.iter()
430            .zip(accounts)
431            .map(|(key, account)| match account {
432                Some(account) => parse_lookup_table(*key, &account),
433                None => Err(ChainError::MissingLookupTable(*key)),
434            })
435            .collect()
436    }
437
438    async fn resolve_token_program(
439        &self,
440        mint: &Pubkey,
441        token_program: Option<Pubkey>,
442    ) -> Result<Pubkey, ChainError> {
443        if let Some(program) = token_program {
444            return ensure_supported_token_program(program);
445        }
446        let account = self.rpc.get_account(mint).await?;
447        ensure_supported_token_program(account.owner)
448    }
449
450    pub(crate) async fn fetch_account(&self, pubkey: &Pubkey) -> Result<Account, ChainError> {
451        self.rpc.get_account(pubkey).await.map_err(ChainError::from)
452    }
453
454    /// Program accounts matching `filters`, fetched at the client's commitment.
455    pub(crate) async fn program_accounts(
456        &self,
457        filters: Vec<solana_rpc_client::api::filter::RpcFilterType>,
458    ) -> Result<Vec<(Pubkey, Account)>, ChainError> {
459        use solana_rpc_client::api::config::{
460            RpcAccountInfoConfig, RpcProgramAccountsConfig, UiAccountEncoding,
461        };
462        self.rpc
463            .get_program_ui_accounts_with_config(
464                &self.program_id,
465                RpcProgramAccountsConfig {
466                    filters: Some(filters),
467                    account_config: RpcAccountInfoConfig {
468                        encoding: Some(UiAccountEncoding::Base64),
469                        commitment: Some(self.commitment),
470                        ..RpcAccountInfoConfig::default()
471                    },
472                    ..RpcProgramAccountsConfig::default()
473                },
474            )
475            .await?
476            .into_iter()
477            .map(|(address, account)| {
478                account
479                    .decode::<Account>()
480                    .map(|account| (address, account))
481                    .ok_or(ChainError::InvalidAccountData)
482            })
483            .collect()
484    }
485
486    pub(crate) async fn fetch_optional_account(
487        &self,
488        pubkey: &Pubkey,
489    ) -> Result<Option<Account>, ChainError> {
490        Ok(self
491            .rpc
492            .get_account_with_commitment(pubkey, self.commitment)
493            .await?
494            .value)
495    }
496
497    /// Submit and wait for confirmation at the client commitment; execution failure is an error.
498    pub async fn send_instructions_with_options(
499        &self,
500        instructions: Vec<Instruction>,
501        fee_payer: &(dyn Signer + Sync),
502        extra_signers: &[&(dyn Signer + Sync)],
503        options: SendOptions,
504    ) -> Result<Signature, ChainError> {
505        let (signature, blockhash) = self
506            .submit_instructions(instructions, fee_payer, extra_signers, options, None, None)
507            .await?;
508        self.confirm_submitted(signature, blockhash).await
509    }
510
511    /// Submit without confirmation using a blockhash shared across the caller's batch.
512    pub async fn send_instructions_with_blockhash(
513        &self,
514        instructions: Vec<Instruction>,
515        fee_payer: &(dyn Signer + Sync),
516        extra_signers: &[&(dyn Signer + Sync)],
517        options: SendOptions,
518        blockhash: solana_sdk::hash::Hash,
519    ) -> Result<Signature, ChainError> {
520        self.submit_instructions(
521            instructions,
522            fee_payer,
523            extra_signers,
524            options,
525            None,
526            Some(blockhash),
527        )
528        .await
529        .map(|(signature, _)| signature)
530    }
531
532    async fn submit_instructions(
533        &self,
534        mut instructions: Vec<Instruction>,
535        fee_payer: &(dyn Signer + Sync),
536        extra_signers: &[&(dyn Signer + Sync)],
537        options: SendOptions,
538        native_budget: Option<NativeSolBudget>,
539        blockhash: Option<solana_sdk::hash::Hash>,
540    ) -> Result<(Signature, solana_sdk::hash::Hash), ChainError> {
541        if let Some(limit) = options.compute_unit_limit {
542            instructions.insert(
543                0,
544                solana_compute_budget_interface::ComputeBudgetInstruction::set_compute_unit_limit(
545                    limit,
546                ),
547            );
548        }
549        let recent = match blockhash {
550            Some(hash) => hash,
551            None => self.rpc.get_latest_blockhash().await?,
552        };
553
554        let mut tx = Transaction::new_with_payer(&instructions, Some(&fee_payer.pubkey()));
555        tx.message.recent_blockhash = recent;
556        if let Some(budget) = native_budget {
557            let fee_lamports = if fee_payer.pubkey() == budget.owner {
558                self.rpc.get_fee_for_message(&tx.message).await?
559            } else {
560                0
561            };
562            let available_lamports = self.rpc.get_balance(&budget.owner).await?;
563            budget.validate(available_lamports, fee_lamports)?;
564        }
565        let mut signers: Vec<&(dyn Signer + Sync)> = Vec::with_capacity(1 + extra_signers.len());
566        signers.push(fee_payer);
567        for signer in extra_signers {
568            if signer.pubkey() != fee_payer.pubkey() {
569                signers.push(*signer);
570            }
571        }
572        sign_transaction(&mut tx, &signers, recent)?;
573        let signature = self.rpc.send_transaction(&tx).await?;
574        Ok((signature, recent))
575    }
576    async fn confirm_submitted(
577        &self,
578        signature: Signature,
579        blockhash: solana_sdk::hash::Hash,
580    ) -> Result<Signature, ChainError> {
581        #[cfg(feature = "ws-client")]
582        if let Some(url) = &self.confirmation_ws_url {
583            let statuses = self
584                .confirm_signatures(url, &[signature], std::time::Duration::from_secs(60))
585                .await?;
586            return match statuses.as_slice() {
587                [super::ConfirmationStatus::Confirmed] => Ok(signature),
588                [super::ConfirmationStatus::Failed(error)] => Err(ChainError::Confirmation(
589                    format!("transaction {signature} failed: {error}"),
590                )),
591                _ => Err(ChainError::Confirmation(format!(
592                    "transaction {signature} confirmation unresolved"
593                ))),
594            };
595        }
596        self.rpc
597            .confirm_transaction_with_spinner(&signature, &blockhash, self.commitment)
598            .await?;
599        Ok(signature)
600    }
601}
602
603fn sign_transaction(
604    transaction: &mut Transaction,
605    signers: &[&(dyn Signer + Sync)],
606    recent_blockhash: solana_sdk::hash::Hash,
607) -> Result<(), ChainError> {
608    transaction.try_sign(signers, recent_blockhash)?;
609    Ok(())
610}
611
612fn ensure_supported_token_program(program: Pubkey) -> Result<Pubkey, ChainError> {
613    if program == TOKEN_PROGRAM_ID || program == TOKEN_2022_PROGRAM_ID {
614        Ok(program)
615    } else {
616        Err(ChainError::InvalidAccountData)
617    }
618}
619
620#[cfg(test)]
621#[path = "rpc_tests.rs"]
622mod tests;
623
624#[cfg(test)]
625#[path = "rpc_mock.rs"]
626mod mock;