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