Skip to main content

acta_maker_sdk/ws/vault/
sell.rs

1use std::future::Future;
2use std::time::Duration;
3
4use solana_sdk::pubkey::Pubkey;
5use uuid::Uuid;
6
7use crate::chain::vault::{
8    ComputeBudgetBounds, Counterparty, VaultManager, VaultSaleTerms, VaultSpreadTerms,
9    VaultTradeTerms,
10};
11use crate::chain::{ChainClient, ChainError, MarketInfo};
12use crate::spreads::{SpreadError, SpreadType};
13use crate::types::ids::OrderId;
14use crate::types::{OptionTerms, Quantity, Strike, TakerSide, TimeoutSeconds};
15use crate::ws::managed::ManagedReceiveError;
16use crate::ws::types::{
17    AcceptQuoteMessage, ClientMessage, Delivery, QuoteReceivedMessage, RfqRequestMessage,
18    ServerMessage, SponsoredTxToSignData,
19};
20
21use super::client::{VaultOperatorClient, VaultRequestError};
22use super::gate::SendGate;
23use super::tx::VaultTxError;
24
25/// An option or cash spread the vault writes and sells through an RFQ.
26#[derive(Debug, Clone)]
27pub struct VaultSale {
28    pub market_pda: Pubkey,
29    pub market: MarketInfo,
30    /// Put products (cash-secured put, put spread) need a put market.
31    pub terms: OptionTerms,
32    pub quantity: Quantity,
33    pub rfq_timeout: TimeoutSeconds,
34    /// Bounds quote collection with `take`, and then serving plus `writer`, verification
35    /// and signing; RFQ, accept and submit commands are bounded by the request timeout.
36    pub window: Duration,
37    pub bounds: ComputeBudgetBounds,
38}
39
40impl VaultSale {
41    /// The terms the writer signs to fill `quote`.
42    pub(super) fn signed_terms(
43        &self,
44        quote: &QuoteReceivedMessage,
45    ) -> Result<VaultSaleTerms, ChainError> {
46        let market = &self.market;
47        let spread = |kind, lower_strike: Strike, upper_strike: Strike| {
48            VaultSaleTerms::Spread(VaultSpreadTerms {
49                underlying_mint: market.underlying_mint,
50                quote_mint: market.quote_mint,
51                expiry_ts: market.expiry_ts,
52                kind,
53                side: quote.side,
54                lower_strike: lower_strike.value(),
55                upper_strike: upper_strike.value(),
56                quantity: self.quantity.value(),
57                price: quote.price.value(),
58                valid_until: quote.valid_until.as_unix_seconds(),
59                nonce: quote.nonce.value(),
60            })
61        };
62        Ok(match self.terms {
63            OptionTerms::CashPut { strike } => {
64                spread(SpreadType::PutSpread, Strike::new(0), strike)
65            }
66            OptionTerms::CoveredCall { strike } | OptionTerms::CashSecuredPut { strike } => {
67                VaultSaleTerms::Physical(VaultTradeTerms {
68                    underlying_mint: market.underlying_mint,
69                    quote_mint: market.quote_mint,
70                    expiry_ts: market.expiry_ts,
71                    is_put: market.is_put,
72                    side: quote.side,
73                    strike: strike.value(),
74                    quantity: self.quantity.value(),
75                    price: quote.price.value(),
76                    valid_until: quote.valid_until.as_unix_seconds(),
77                    nonce: quote.nonce.value(),
78                })
79            }
80            OptionTerms::CallSpread {
81                lower_strike,
82                upper_strike,
83            } => spread(SpreadType::CallSpread, lower_strike, upper_strike),
84            OptionTerms::PutSpread {
85                lower_strike,
86                upper_strike,
87            } => spread(SpreadType::PutSpread, lower_strike, upper_strike),
88        })
89    }
90}
91
92/// The bot's decisions and bookkeeping inside [`VaultOperatorClient::sell_rfq`].
93pub trait SaleDesk: Send {
94    type Error: std::fmt::Debug + std::fmt::Display + Send + Sync + 'static;
95
96    /// Whether to take `quote`; asked for each quote until one is taken.
97    fn take(
98        &mut self,
99        quote: &QuoteReceivedMessage,
100    ) -> impl Future<Output = Result<bool, Self::Error>> + Send;
101
102    /// Runs before `AcceptQuote` is sent for the taken quote.
103    fn accepting(&mut self, _quote: &QuoteReceivedMessage) -> Result<(), Self::Error> {
104        Ok(())
105    }
106
107    /// Re-checks the vault once the transaction is served and returns the writer to sign as.
108    fn writer(&mut self) -> impl Future<Output = Result<VaultManager, Self::Error>> + Send;
109
110    /// Runs with the signed transaction before it is submitted; an error leaves it unsubmitted.
111    fn signed(&mut self, _sale: &SignedSale) -> Result<(), Self::Error> {
112        Ok(())
113    }
114}
115
116/// A sale accepted by the server after the vault/user participant signed it.
117///
118/// The keeper still supplies transaction signature slot 0 and submits the transaction, so this
119/// does not carry an on-chain transaction id.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct SignedSale {
122    pub order_id: OrderId,
123    /// Base58 signature made by the vault delegate or user participant over the served message.
124    pub participant_signature: String,
125    pub last_valid_block_height: u64,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum SaleOutcome {
130    /// No quote was taken within the window.
131    Unfilled,
132    /// The server accepted the participant-signed transaction for keeper cosigning; its chain
133    /// outcome is pending.
134    Submitted(SignedSale),
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum SaleStage {
139    Request,
140    Take,
141    Accept,
142    Sign,
143}
144
145#[derive(Debug, thiserror::Error)]
146#[non_exhaustive]
147pub enum SaleError<E> {
148    #[error("market_pda is not the program's market for these terms")]
149    Market,
150    #[error("spread terms: {0}")]
151    Spread(SpreadError),
152    #[error("sale desk: {0}")]
153    Desk(E),
154    #[error("{stage:?} failed")]
155    Rejected {
156        stage: SaleStage,
157        #[source]
158        error: VaultRequestError,
159    },
160    #[error(transparent)]
161    Stream(#[from] ManagedReceiveError),
162    #[error("the session changed during the sale")]
163    SessionChanged,
164    #[error("the accepted quote was not served within the window")]
165    Unserved,
166    /// `take`, or `writer` plus verification and signing, outlived the window.
167    #[error("{0:?} did not finish within the window")]
168    TimedOut(SaleStage),
169    #[error(transparent)]
170    Chain(#[from] ChainError),
171    #[error(transparent)]
172    Sign(#[from] VaultTxError),
173    #[error("submitting the signed sale failed")]
174    Submit {
175        sale: SignedSale,
176        #[source]
177        error: VaultRequestError,
178    },
179}
180
181impl<E> SaleError<E> {
182    /// The participant-signed sale this failure may have left with the keeper. `None`: nothing
183    /// signed reached the server, so the sale cannot execute.
184    #[must_use]
185    pub fn in_flight(&self) -> Option<&SignedSale> {
186        match self {
187            Self::Submit { sale, error } if error.delivery() != Some(Delivery::NotForwarded) => {
188                Some(sale)
189            }
190            _ => None,
191        }
192    }
193}
194
195impl VaultOperatorClient {
196    /// Sells `sale` in `connection_epoch`: requests the RFQ, offers each quote to
197    /// `desk`, accepts the first it takes, verifies and signs the served
198    /// transaction against the chain counterparty, and submits it. Every send
199    /// goes through `gate`.
200    pub async fn sell_rfq<D: SaleDesk>(
201        &self,
202        chain: &ChainClient,
203        sale: &VaultSale,
204        connection_epoch: u64,
205        gate: &(impl SendGate + Sync),
206        desk: &mut D,
207    ) -> Result<SaleOutcome, SaleError<D::Error>> {
208        let market = &sale.market;
209        let canonical = crate::quote::market_pda(
210            &chain.program_id(),
211            &market.underlying_mint,
212            &market.quote_mint,
213            market.expiry_ts,
214            market.is_put,
215        );
216        if canonical != sale.market_pda || sale.terms.position_type().is_put() != market.is_put {
217            return Err(SaleError::Market);
218        }
219        if let Some(upper) = sale.terms.upper_strike() {
220            let product = sale.terms.position_type();
221            if product != crate::PositionType::CashPut && sale.terms.strike().value() == 0 {
222                return Err(SaleError::Spread(SpreadError::InvalidStrikes));
223            }
224            crate::spreads::validate_strikes(
225                SpreadType::try_from(u8::from(sale.terms.to_chain().position_type()))
226                    .map_err(SaleError::Spread)?,
227                sale.terms.to_chain().strike().value(),
228                upper.value(),
229            )
230            .map_err(SaleError::Spread)?;
231        }
232        let mut messages = self.subscribe_messages();
233        let request_id = Uuid::new_v4();
234        self.gated_command(
235            gate,
236            connection_epoch,
237            ClientMessage::RfqRequest(RfqRequestMessage {
238                request_id,
239                market: crate::MarketId::new(crate::Address32::new(sale.market_pda.to_bytes())),
240                terms: sale.terms,
241                side: TakerSide::Sell,
242                quantity: sale.quantity,
243                timeout_seconds: sale.rfq_timeout,
244            }),
245        )
246        .await
247        .map_err(|error| SaleError::Rejected {
248            stage: SaleStage::Request,
249            error,
250        })?;
251
252        let mut rfq_id = None;
253        let mut taken: Option<QuoteReceivedMessage> = None;
254        let mut deadline = tokio::time::Instant::now() + sale.window;
255        loop {
256            let Ok(inbound) = tokio::time::timeout_at(deadline, messages.recv()).await else {
257                return match taken {
258                    None => Ok(SaleOutcome::Unfilled),
259                    Some(_) => Err(SaleError::Unserved),
260                };
261            };
262            let inbound = inbound?;
263            if inbound.connection_epoch != connection_epoch {
264                return Err(SaleError::SessionChanged);
265            }
266            match inbound.message() {
267                ServerMessage::RfqCreated(created) if created.request_id == Some(request_id) => {
268                    rfq_id = Some(created.rfq_id);
269                }
270                ServerMessage::QuoteReceived(quote) if taken.is_none() => {
271                    if Some(quote.rfq_id) == rfq_id
272                        && quote.terms == sale.terms
273                        && quote.side == TakerSide::Sell
274                        && tokio::time::timeout_at(deadline, desk.take(quote))
275                            .await
276                            .map_err(|_| SaleError::TimedOut(SaleStage::Take))?
277                            .map_err(SaleError::Desk)?
278                    {
279                        desk.accepting(quote).map_err(SaleError::Desk)?;
280                        self.gated_command(
281                            gate,
282                            connection_epoch,
283                            ClientMessage::AcceptQuote(AcceptQuoteMessage {
284                                request_id: Uuid::new_v4(),
285                                rfq_id: quote.rfq_id,
286                                maker: quote.maker,
287                                order_id: quote.order_id,
288                            }),
289                        )
290                        .await
291                        .map_err(|error| SaleError::Rejected {
292                            stage: SaleStage::Accept,
293                            error,
294                        })?;
295                        taken = Some(quote.clone());
296                        deadline = tokio::time::Instant::now() + sale.window;
297                    }
298                }
299                ServerMessage::SponsoredTxToSign(served) => {
300                    if let Some(quote) = taken.as_ref().filter(|q| q.order_id == served.order_id) {
301                        let signed = tokio::time::timeout_at(
302                            deadline,
303                            self.sign_sale(chain, sale, quote, served, desk),
304                        )
305                        .await
306                        .map_err(|_| SaleError::TimedOut(SaleStage::Sign))??;
307                        return self.submit_sale(gate, connection_epoch, signed, desk).await;
308                    }
309                }
310                _ => {}
311            }
312        }
313    }
314
315    async fn sign_sale<D: SaleDesk>(
316        &self,
317        chain: &ChainClient,
318        sale: &VaultSale,
319        quote: &QuoteReceivedMessage,
320        served: &SponsoredTxToSignData,
321        desk: &mut D,
322    ) -> Result<(SignedSale, crate::ws::types::SubmitSignedSponsoredTxData), SaleError<D::Error>>
323    {
324        let writer = desk.writer().await.map_err(SaleError::Desk)?;
325        let maker = Pubkey::from(quote.maker);
326        let counterparty = Counterparty::load(chain, maker).await?;
327        let keeper = chain.hot_authority().await?;
328        let tables = chain
329            .address_lookup_tables(
330                &crate::chain::vault::served_lookup_table_keys(&served.tx_base64)
331                    .map_err(VaultTxError::from)?,
332            )
333            .await?;
334        let (submit, participant_signature) = self
335            .verify_and_sign_sponsored_tx_with_participant_signature(
336                served,
337                &counterparty.sale(&writer, keeper, sale.signed_terms(quote)?),
338                &tables,
339                sale.bounds,
340            )?;
341        let signed = SignedSale {
342            order_id: served.order_id,
343            participant_signature,
344            last_valid_block_height: served.last_valid_block_height,
345        };
346        Ok((signed, submit))
347    }
348
349    async fn submit_sale<D: SaleDesk>(
350        &self,
351        gate: &(impl SendGate + Sync),
352        connection_epoch: u64,
353        (signed, submit): (SignedSale, crate::ws::types::SubmitSignedSponsoredTxData),
354        desk: &mut D,
355    ) -> Result<SaleOutcome, SaleError<D::Error>> {
356        desk.signed(&signed).map_err(SaleError::Desk)?;
357        match self
358            .gated_command(
359                gate,
360                connection_epoch,
361                ClientMessage::SubmitSignedSponsoredTx(submit),
362            )
363            .await
364        {
365            Ok(()) => Ok(SaleOutcome::Submitted(signed)),
366            Err(error) => Err(SaleError::Submit {
367                sale: signed,
368                error,
369            }),
370        }
371    }
372}