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    MakerDepositIxArgs, MakerWithdrawIxArgs, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID,
12    build_maker_deposit_ixs, build_maker_withdraw_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 MakerDepositArgs {
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 MakerWithdrawArgs {
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 holder: 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_sender(
133            ObservedSender(shared_sender(rpc_url)),
134            solana_rpc_client::rpc_client::RpcClientConfig::with_commitment(commitment),
135        );
136        Self {
137            rpc,
138            program_id,
139            commitment,
140            #[cfg(feature = "ws-client")]
141            confirmation_ws_url: None,
142        }
143    }
144
145    /// Submit and wait for confirmation at the client commitment; execution failure is an error.
146    pub async fn maker_deposit(
147        &self,
148        args: MakerDepositArgs,
149        maker_owner: &(dyn Signer + Sync),
150        fee_payer: Option<&(dyn Signer + Sync)>,
151    ) -> Result<Signature, ChainError> {
152        let payer = fee_payer.map_or(maker_owner, |payer| payer);
153        let ixs = self.build_maker_deposit_ixs(&args, payer.pubkey()).await?;
154        self.send_instructions_with_options(ixs, payer, &[maker_owner], SendOptions::default())
155            .await
156    }
157
158    /// Submit and wait for confirmation at the client commitment; execution failure is an error.
159    pub async fn maker_withdraw(
160        &self,
161        args: MakerWithdrawArgs,
162        maker_owner: &(dyn Signer + Sync),
163        fee_payer: Option<&(dyn Signer + Sync)>,
164    ) -> Result<Signature, ChainError> {
165        let payer = fee_payer.map_or(maker_owner, |payer| payer);
166        let ixs = self.build_maker_withdraw_ixs(&args, payer.pubkey()).await?;
167        self.send_instructions_with_options(ixs, payer, &[maker_owner], SendOptions::default())
168            .await
169    }
170
171    /// Submit and wait for confirmation at the client commitment; execution failure is an error.
172    pub async fn fund_position(
173        &self,
174        args: FundPositionArgs,
175        maker_owner: &(dyn Signer + Sync),
176        fee_payer: Option<&(dyn Signer + Sync)>,
177    ) -> Result<Signature, ChainError> {
178        let payer = fee_payer.map_or(maker_owner, |payer| payer);
179        let prepared = self.prepare_fund_position(&args, payer.pubkey()).await?;
180        let (signature, blockhash) = self
181            .submit_instructions(
182                prepared.instructions,
183                payer,
184                &[maker_owner],
185                SendOptions::default(),
186                prepared.native_budget,
187                None,
188            )
189            .await?;
190        self.confirm_submitted(signature, blockhash).await
191    }
192
193    pub async fn build_maker_deposit_ixs(
194        &self,
195        args: &MakerDepositArgs,
196        fee_payer: Pubkey,
197    ) -> Result<Vec<Instruction>, ChainError> {
198        let token_program = self
199            .resolve_token_program(&args.premium_mint, args.token_program)
200            .await?;
201        build_maker_deposit_ixs(
202            &self.program_id,
203            &MakerDepositIxArgs {
204                maker_owner: args.maker_owner,
205                amount: args.amount,
206                premium_mint: args.premium_mint,
207                token_program,
208                create_atas: args.create_atas,
209            },
210            fee_payer,
211        )
212        .map_err(ChainError::from)
213    }
214
215    pub async fn build_maker_withdraw_ixs(
216        &self,
217        args: &MakerWithdrawArgs,
218        fee_payer: Pubkey,
219    ) -> Result<Vec<Instruction>, ChainError> {
220        let token_program = self
221            .resolve_token_program(&args.premium_mint, args.token_program)
222            .await?;
223        build_maker_withdraw_ixs(
224            &self.program_id,
225            &MakerWithdrawIxArgs {
226                maker_owner: args.maker_owner,
227                amount: args.amount,
228                premium_mint: args.premium_mint,
229                token_program,
230                create_atas: args.create_atas,
231            },
232            fee_payer,
233        )
234        .map_err(ChainError::from)
235    }
236
237    pub async fn build_fund_position_ixs(
238        &self,
239        args: &FundPositionArgs,
240        fee_payer: Pubkey,
241    ) -> Result<Vec<Instruction>, ChainError> {
242        Ok(self
243            .prepare_fund_position(args, fee_payer)
244            .await?
245            .instructions)
246    }
247
248    async fn prepare_fund_position(
249        &self,
250        args: &FundPositionArgs,
251        fee_payer: Pubkey,
252    ) -> Result<PreparedFundPosition, ChainError> {
253        let position_data = self.fetch_account(&args.position_pda).await?;
254        let pos = PositionInfo::from_account(&position_data, &self.program_id)?;
255        let market_data = self.fetch_account(&pos.market_pda).await?;
256        let market = MarketInfo::from_account(&market_data, &self.program_id)?;
257
258        let snapshot = super::batch::PositionSnapshot {
259            address: args.position_pda,
260            market_address: pos.market_pda,
261            position: pos.clone(),
262            market: market.clone(),
263        };
264        let mut fund_ixs = snapshot.fund_instructions(
265            &self.program_id,
266            args.holder,
267            fee_payer,
268            args.create_atas,
269        )?;
270
271        let NativeSolFunding::WrapIfNeeded { reserve_lamports } = args.native_sol else {
272            return Ok(PreparedFundPosition {
273                instructions: fund_ixs,
274                native_budget: None,
275            });
276        };
277        let (settlement_mint, settlement_program) = match pos.terms.position_type() {
278            crate::types::PositionType::CoveredCall => {
279                (market.quote_mint, market.quote_token_program_id)
280            }
281            crate::types::PositionType::CashSecuredPut => {
282                (market.underlying_mint, market.underlying_token_program_id)
283            }
284            _ => return Err(ChainError::InvalidSettlementState),
285        };
286        if !is_native_mint(&settlement_mint) {
287            return Ok(PreparedFundPosition {
288                instructions: fund_ixs,
289                native_budget: None,
290            });
291        }
292        let expected_settlement = required_settlement(
293            pos.terms.to_chain().position_type().into(),
294            pos.terms.strike().value(),
295            pos.quantity.value(),
296            market.quote_decimals.value(),
297            market.underlying_decimals.value(),
298        )?;
299        if expected_settlement == 0 {
300            return Err(ChainError::InvalidAccountData);
301        }
302        if settlement_program != crate::chain::wsol::TOKEN_PROGRAM_ID {
303            return Err(ChainError::InvalidNativeTokenProgram(settlement_program));
304        }
305        let funding_ata = crate::chain::ix::derive_associated_token_address(
306            &args.holder,
307            &settlement_mint,
308            &settlement_program,
309        );
310        let position_funding_ata = crate::chain::ix::derive_associated_token_address(
311            &args.position_pda,
312            &settlement_mint,
313            &settlement_program,
314        );
315        let (funding_account, additional_ata_rent_lamports) =
316            if args.create_atas && fee_payer == args.holder {
317                let [position_account, funding_account]: [Option<Account>; 2] = self
318                    .rpc
319                    .get_multiple_accounts_with_commitment(
320                        &[position_funding_ata, funding_ata],
321                        self.commitment,
322                    )
323                    .await?
324                    .value
325                    .try_into()
326                    .map_err(|_| ChainError::InvalidAccountData)?;
327                let rent = if position_account.is_none() {
328                    token_account_rent_lamports(&self.rpc).await?
329                } else {
330                    0
331                };
332                (funding_account, rent)
333            } else {
334                (
335                    self.rpc
336                        .get_account_with_commitment(&funding_ata, self.commitment)
337                        .await?
338                        .value,
339                    0,
340                )
341            };
342        match plan_wsol_wrap(
343            &self.rpc,
344            funding_account,
345            WsolWrapRequest {
346                owner: args.holder,
347                funding_ata,
348                token_program: settlement_program,
349                expected_settlement,
350                reserve_lamports,
351                allow_account_creation: args.create_atas,
352                fee_payer,
353                additional_ata_rent_lamports,
354            },
355        )
356        .await?
357        {
358            WrapPlan::NotNeeded => Ok(PreparedFundPosition {
359                instructions: fund_ixs,
360                native_budget: None,
361            }),
362            WrapPlan::Wrap {
363                instructions: wrap_ixs,
364                budget,
365            } => {
366                let deposit_ix = fund_ixs.pop().ok_or(ChainError::InvalidAccountData)?;
367                fund_ixs.extend(wrap_ixs);
368                fund_ixs.push(deposit_ix);
369                Ok(PreparedFundPosition {
370                    instructions: fund_ixs,
371                    native_budget: Some(budget),
372                })
373            }
374        }
375    }
376
377    pub async fn fetch_position_info(
378        &self,
379        position_pda: &Pubkey,
380    ) -> Result<PositionInfo, ChainError> {
381        let data = self.fetch_account(position_pda).await?;
382        PositionInfo::from_account(&data, &self.program_id)
383    }
384
385    pub async fn fetch_market_info(&self, market_pda: &Pubkey) -> Result<MarketInfo, ChainError> {
386        let market_data = self.fetch_account(market_pda).await?;
387        MarketInfo::from_account(&market_data, &self.program_id)
388    }
389
390    pub async fn fetch_market_quote_mint(&self, market_pda: &Pubkey) -> Result<Pubkey, ChainError> {
391        Ok(self.fetch_market_info(market_pda).await?.quote_mint)
392    }
393
394    /// Reads the canonical GlobalConfig PDA and returns its checked hot authority.
395    ///
396    /// Vault opens use this key as both transaction fee payer and account 0, so
397    /// accepting a venue-provided value would let the venue choose the signer.
398    pub async fn hot_authority(&self) -> Result<Pubkey, ChainError> {
399        let (global_config, bump) =
400            Pubkey::find_program_address(&[b"global_config"], &self.program_id);
401        let account = self.fetch_account(&global_config).await?;
402        let config = GlobalConfigInfo::from_account(&account, &self.program_id)?;
403        if config.bump != bump {
404            return Err(ChainError::Identity("global config PDA"));
405        }
406        Ok(config.hot_authority)
407    }
408
409    /// Fetch the raw token balance (u64 atoms) of a token account.
410    pub async fn fetch_token_balance(&self, token_account: &Pubkey) -> Result<u64, ChainError> {
411        let result = self.rpc.get_token_account_balance(token_account).await?;
412        result
413            .amount
414            .parse::<u64>()
415            .map_err(|_| ChainError::InvalidAccountData)
416    }
417
418    /// Active lookup tables by key, for verifying served v0 transactions.
419    pub async fn address_lookup_tables(
420        &self,
421        keys: &[Pubkey],
422    ) -> Result<Vec<AddressLookupTableAccount>, ChainError> {
423        let accounts = self
424            .rpc
425            .get_multiple_accounts_with_commitment(keys, self.commitment)
426            .await?
427            .value;
428        keys.iter()
429            .zip(accounts)
430            .map(|(key, account)| match account {
431                Some(account) => parse_lookup_table(*key, &account),
432                None => Err(ChainError::MissingLookupTable(*key)),
433            })
434            .collect()
435    }
436
437    async fn resolve_token_program(
438        &self,
439        mint: &Pubkey,
440        token_program: Option<Pubkey>,
441    ) -> Result<Pubkey, ChainError> {
442        if let Some(program) = token_program {
443            return ensure_supported_token_program(program);
444        }
445        let account = self.rpc.get_account(mint).await?;
446        ensure_supported_token_program(account.owner)
447    }
448
449    pub(crate) async fn fetch_account(&self, pubkey: &Pubkey) -> Result<Account, ChainError> {
450        self.rpc.get_account(pubkey).await.map_err(ChainError::from)
451    }
452
453    /// Program accounts matching `filters`, fetched at the client's commitment.
454    pub(crate) async fn program_accounts(
455        &self,
456        filters: Vec<solana_rpc_client::api::filter::RpcFilterType>,
457    ) -> Result<Vec<(Pubkey, Account)>, ChainError> {
458        use solana_rpc_client::api::config::{
459            RpcAccountInfoConfig, RpcProgramAccountsConfig, UiAccountEncoding,
460        };
461        self.rpc
462            .get_program_ui_accounts_with_config(
463                &self.program_id,
464                RpcProgramAccountsConfig {
465                    filters: Some(filters),
466                    account_config: RpcAccountInfoConfig {
467                        encoding: Some(UiAccountEncoding::Base64),
468                        commitment: Some(self.commitment),
469                        ..RpcAccountInfoConfig::default()
470                    },
471                    ..RpcProgramAccountsConfig::default()
472                },
473            )
474            .await?
475            .into_iter()
476            .map(|(address, account)| {
477                account
478                    .decode::<Account>()
479                    .map(|account| (address, account))
480                    .ok_or(ChainError::InvalidAccountData)
481            })
482            .collect()
483    }
484
485    pub(crate) async fn fetch_optional_account(
486        &self,
487        pubkey: &Pubkey,
488    ) -> Result<Option<Account>, ChainError> {
489        Ok(self
490            .rpc
491            .get_account_with_commitment(pubkey, self.commitment)
492            .await?
493            .value)
494    }
495
496    /// Submit and wait for confirmation at the client commitment; execution failure is an error.
497    pub async fn send_instructions_with_options(
498        &self,
499        instructions: Vec<Instruction>,
500        fee_payer: &(dyn Signer + Sync),
501        extra_signers: &[&(dyn Signer + Sync)],
502        options: SendOptions,
503    ) -> Result<Signature, ChainError> {
504        let (signature, blockhash) = self
505            .submit_instructions(instructions, fee_payer, extra_signers, options, None, None)
506            .await?;
507        self.confirm_submitted(signature, blockhash).await
508    }
509
510    /// Submit without confirmation using a blockhash shared across the caller's batch.
511    pub async fn send_instructions_with_blockhash(
512        &self,
513        instructions: Vec<Instruction>,
514        fee_payer: &(dyn Signer + Sync),
515        extra_signers: &[&(dyn Signer + Sync)],
516        options: SendOptions,
517        blockhash: solana_sdk::hash::Hash,
518    ) -> Result<Signature, ChainError> {
519        self.submit_instructions(
520            instructions,
521            fee_payer,
522            extra_signers,
523            options,
524            None,
525            Some(blockhash),
526        )
527        .await
528        .map(|(signature, _)| signature)
529    }
530
531    async fn submit_instructions(
532        &self,
533        mut instructions: Vec<Instruction>,
534        fee_payer: &(dyn Signer + Sync),
535        extra_signers: &[&(dyn Signer + Sync)],
536        options: SendOptions,
537        native_budget: Option<NativeSolBudget>,
538        blockhash: Option<solana_sdk::hash::Hash>,
539    ) -> Result<(Signature, solana_sdk::hash::Hash), ChainError> {
540        if let Some(limit) = options.compute_unit_limit {
541            instructions.insert(
542                0,
543                solana_compute_budget_interface::ComputeBudgetInstruction::set_compute_unit_limit(
544                    limit,
545                ),
546            );
547        }
548        let recent = match blockhash {
549            Some(hash) => hash,
550            None => self.rpc.get_latest_blockhash().await?,
551        };
552
553        let mut tx = Transaction::new_with_payer(&instructions, Some(&fee_payer.pubkey()));
554        tx.message.recent_blockhash = recent;
555        if let Some(budget) = native_budget {
556            let fee_lamports = if fee_payer.pubkey() == budget.owner {
557                self.rpc.get_fee_for_message(&tx.message).await?
558            } else {
559                0
560            };
561            let available_lamports = self.rpc.get_balance(&budget.owner).await?;
562            budget.validate(available_lamports, fee_lamports)?;
563        }
564        let mut signers: Vec<&(dyn Signer + Sync)> = Vec::with_capacity(1 + extra_signers.len());
565        signers.push(fee_payer);
566        for signer in extra_signers {
567            if signer.pubkey() != fee_payer.pubkey() {
568                signers.push(*signer);
569            }
570        }
571        sign_transaction(&mut tx, &signers, recent)?;
572        let signature = self.rpc.send_transaction(&tx).await?;
573        Ok((signature, recent))
574    }
575    async fn confirm_submitted(
576        &self,
577        signature: Signature,
578        blockhash: solana_sdk::hash::Hash,
579    ) -> Result<Signature, ChainError> {
580        #[cfg(feature = "ws-client")]
581        if let Some(url) = &self.confirmation_ws_url {
582            let statuses = self
583                .confirm_signatures(url, &[signature], std::time::Duration::from_secs(60))
584                .await?;
585            return match statuses.as_slice() {
586                [super::ConfirmationStatus::Confirmed] => Ok(signature),
587                [super::ConfirmationStatus::Failed(error)] => Err(ChainError::Confirmation(
588                    format!("transaction {signature} failed: {error}"),
589                )),
590                _ => Err(ChainError::Confirmation(format!(
591                    "transaction {signature} confirmation unresolved"
592                ))),
593            };
594        }
595        self.rpc
596            .confirm_transaction_with_spinner(&signature, &blockhash, self.commitment)
597            .await?;
598        Ok(signature)
599    }
600}
601
602fn sign_transaction(
603    transaction: &mut Transaction,
604    signers: &[&(dyn Signer + Sync)],
605    recent_blockhash: solana_sdk::hash::Hash,
606) -> Result<(), ChainError> {
607    transaction.try_sign(signers, recent_blockhash)?;
608    Ok(())
609}
610
611fn ensure_supported_token_program(program: Pubkey) -> Result<Pubkey, ChainError> {
612    if program == TOKEN_PROGRAM_ID || program == TOKEN_2022_PROGRAM_ID {
613        Ok(program)
614    } else {
615        Err(ChainError::InvalidAccountData)
616    }
617}
618
619#[cfg(test)]
620#[path = "rpc_tests.rs"]
621mod tests;
622
623#[cfg(test)]
624#[path = "rpc_mock.rs"]
625mod mock;
626
627fn shared_sender(url: &str) -> std::sync::Arc<solana_rpc_client::http_sender::HttpSender> {
628    static SENDERS: std::sync::LazyLock<
629        std::sync::Mutex<
630            std::collections::HashMap<
631                String,
632                std::sync::Arc<solana_rpc_client::http_sender::HttpSender>,
633            >,
634        >,
635    > = std::sync::LazyLock::new(Default::default);
636    let mut senders = match SENDERS.lock() {
637        Ok(s) => s,
638        Err(p) => p.into_inner(),
639    };
640    senders
641        .entry(url.to_owned())
642        .or_insert_with(|| {
643            std::sync::Arc::new(solana_rpc_client::http_sender::HttpSender::new(url))
644        })
645        .clone()
646}
647struct ObservedSender(std::sync::Arc<solana_rpc_client::http_sender::HttpSender>);
648#[async_trait::async_trait]
649impl solana_rpc_client::rpc_sender::RpcSender for ObservedSender {
650    async fn send(
651        &self,
652        request: solana_rpc_client_api::request::RpcRequest,
653        params: serde_json::Value,
654    ) -> solana_rpc_client_api::client_error::Result<serde_json::Value> {
655        crate::io_observer::record(crate::io_observer::Event::Rpc {
656            method: &request.to_string(),
657        });
658        self.0.send(request, params).await
659    }
660    fn get_transport_stats(&self) -> solana_rpc_client::rpc_sender::RpcTransportStats {
661        self.0.get_transport_stats()
662    }
663    fn url(&self) -> String {
664        self.0.url()
665    }
666}