Skip to main content

acta_maker_sdk/
quote.rs

1//! Quote assembly: [`RfqBinding`] decodes identities and resolves offered terms;
2//! [`QuoteBuilder`] derives the signed preimage and [`QuoteMessage`] from the
3//! same terms, price, expiry and nonce.
4
5use crate::orders::{OrderPreimageArgs, SignerLike, compute_order_id, sign_order_id_with_signer};
6use crate::signing::{AsyncSignerLike, SigningError};
7use crate::types::ids::{Nonce, OrderId, Price, Quantity};
8use crate::types::{OptionTerms, PositionType, QuoteExpiry, TakerSide};
9use crate::wire::{WireError, encode_base58};
10use crate::ws::types::{QuoteMessage, ReplaceQuoteMessage, RfqBroadcastMessage};
11use std::time::Duration;
12use uuid::Uuid;
13
14/// A quote could not be assembled from the RFQ and the supplied values.
15#[derive(Debug, thiserror::Error)]
16#[non_exhaustive]
17pub enum QuoteBuildError {
18    #[error(transparent)]
19    Order(#[from] crate::orders::OrderError),
20    #[error(transparent)]
21    Spread(#[from] crate::spreads::SpreadError),
22    #[error("rfq field `{field}` is not a valid base58 32-byte key: {source}")]
23    Identity {
24        field: &'static str,
25        #[source]
26        source: WireError,
27    },
28
29    #[error("rfq market does not match trusted signing terms: {0}")]
30    MarketBinding(&'static str),
31
32    /// Terms absent from `RfqBroadcast.terms` and `order_options`.
33    #[error("terms {requested:?} are not offered by this rfq; offered: {offered:?}")]
34    TermsNotOffered {
35        requested: OptionTerms,
36        offered: Vec<OptionTerms>,
37    },
38
39    #[error("quote is missing `{0}`")]
40    Missing(&'static str),
41
42    #[error("valid_until {valid_until} is after market expiry {market_expiry}")]
43    ValidUntilAfterMarketExpiry {
44        valid_until: QuoteExpiry,
45        market_expiry: QuoteExpiry,
46    },
47
48    #[error("valid_until {valid_until} is before the venue minimum {min_valid_until}")]
49    ValidUntilTooSoon {
50        valid_until: QuoteExpiry,
51        min_valid_until: QuoteExpiry,
52    },
53
54    /// `valid_until − settlement_buffer_seconds` is not after now, so the
55    /// quote could never be accepted.
56    #[error("valid_until {valid_until} is within the settlement buffer ending at {buffer_end}")]
57    ValidUntilWithinSettlementBuffer {
58        valid_until: QuoteExpiry,
59        buffer_end: QuoteExpiry,
60    },
61
62    #[error("signing failed: {0}")]
63    Signing(#[from] SigningError),
64
65    #[error("taker_buy_unsupported")]
66    TakerBuyUnsupported,
67}
68
69/// An RFQ with its identities decoded and its offered terms resolved.
70///
71/// Build once per RFQ and reuse it for every quote on that RFQ.
72#[derive(Debug, Clone)]
73pub struct RfqBinding {
74    rfq_id: Uuid,
75    chain_id: u64,
76    program_id: [u8; 32],
77    market: [u8; 32],
78    market_expiry: QuoteExpiry,
79    taker: [u8; 32],
80    terms: OptionTerms,
81    quantity: Quantity,
82    offered_terms: Vec<OptionTerms>,
83    side: TakerSide,
84}
85
86impl RfqBinding {
87    /// Decode and validate an inbound [`RfqBroadcastMessage`].
88    ///
89    /// # Errors
90    /// Rejects malformed identities and inconsistent market descriptors. The
91    /// program is pinned to [`crate::ACTA_PROGRAM_ID`], not the WS endpoint.
92    pub fn from_broadcast(rfq: &RfqBroadcastMessage) -> Result<Self, QuoteBuildError> {
93        Self::from_broadcast_for_program(rfq, crate::ACTA_PROGRAM_ID)
94    }
95
96    /// Use a caller-configured deployment ID, never an ID copied from the RFQ.
97    ///
98    /// # Errors
99    /// Rejects identities or market terms inconsistent with the expected program.
100    pub fn from_broadcast_for_program(
101        rfq: &RfqBroadcastMessage,
102        expected_program: [u8; 32],
103    ) -> Result<Self, QuoteBuildError> {
104        let side = rfq.side;
105        if side.is_taker_buy() {
106            return Err(QuoteBuildError::TakerBuyUnsupported);
107        }
108        let terms = rfq.terms;
109        if terms.is_spread() {
110            let upper = terms
111                .upper_strike()
112                .ok_or(QuoteBuildError::MarketBinding("spread terms"))?;
113            if terms.position_type() != PositionType::CashPut && terms.strike().value() == 0 {
114                return Err(crate::spreads::SpreadError::InvalidStrikes.into());
115            }
116            crate::spreads::validate_strikes(
117                crate::spreads::SpreadType::try_from(u8::from(terms.to_chain().position_type()))?,
118                terms.to_chain().strike().value(),
119                upper.value(),
120            )?;
121            if rfq.quantity.value() == 0
122                || rfq.order_options.len() != 1
123                || rfq.order_options[0] != terms
124            {
125                return Err(QuoteBuildError::MarketBinding("spread pair"));
126            }
127        } else if rfq
128            .order_options
129            .iter()
130            .any(|option| option.is_spread() || option.position_type() != terms.position_type())
131        {
132            return Err(QuoteBuildError::MarketBinding("physical order_options"));
133        }
134        let (market, market_expiry) = validate_market_binding(rfq, expected_program)?;
135        let mut offered_terms = Vec::with_capacity(1 + rfq.order_options.len());
136        offered_terms.push(terms);
137        offered_terms.extend(
138            rfq.order_options
139                .iter()
140                .copied()
141                .filter(|option| *option != terms),
142        );
143
144        Ok(Self {
145            rfq_id: rfq.rfq_id,
146            chain_id: rfq.market.chain_id.value(),
147            program_id: expected_program,
148            market,
149            market_expiry,
150            taker: rfq.taker.0,
151            terms,
152            quantity: rfq.quantity,
153            offered_terms,
154            side,
155        })
156    }
157
158    #[must_use]
159    pub const fn rfq_id(&self) -> Uuid {
160        self.rfq_id
161    }
162
163    /// RFQ quantity used for pricing and included in the signed preimage.
164    #[must_use]
165    pub const fn quantity(&self) -> Quantity {
166        self.quantity
167    }
168
169    #[must_use]
170    pub const fn position_type(&self) -> PositionType {
171        self.terms.position_type()
172    }
173
174    /// Complete terms selected by the taker for this RFQ.
175    #[must_use]
176    pub const fn terms(&self) -> OptionTerms {
177        self.terms
178    }
179
180    #[must_use]
181    pub const fn side(&self) -> TakerSide {
182        self.side
183    }
184
185    /// Latest `valid_until` the venue accepts for this RFQ.
186    #[must_use]
187    pub const fn market_expiry(&self) -> QuoteExpiry {
188        self.market_expiry
189    }
190
191    /// Every term set this RFQ will accept a quote on.
192    #[must_use]
193    pub fn offered_terms(&self) -> &[OptionTerms] {
194        &self.offered_terms
195    }
196
197    /// Start a quote on this RFQ's requested terms.
198    pub const fn quote(&self) -> QuoteBuilder<'_> {
199        QuoteBuilder {
200            binding: self,
201            terms: self.terms,
202            price: None,
203            valid_until: None,
204            min_valid_until: None,
205            buffer_end: None,
206            nonce: None,
207            maker: None,
208        }
209    }
210}
211
212fn validate_market_binding(
213    rfq: &RfqBroadcastMessage,
214    expected_program: [u8; 32],
215) -> Result<([u8; 32], QuoteExpiry), QuoteBuildError> {
216    use QuoteBuildError::MarketBinding;
217    use solana_pubkey::Pubkey;
218
219    let descriptor = &rfq.market;
220    if descriptor.chain_id.value() != 0 {
221        return Err(MarketBinding("chain_id"));
222    }
223    if descriptor.program_id.0 != expected_program {
224        return Err(MarketBinding("program_id"));
225    }
226    if descriptor.is_put != rfq.terms.position_type().is_put() {
227        return Err(MarketBinding("terms"));
228    }
229    let underlying = descriptor.underlying_mint.0;
230    let quote = descriptor.quote_mint.0;
231    let (collateral, settlement) = if rfq.terms.is_spread() {
232        (quote, quote)
233    } else if descriptor.is_put {
234        (quote, underlying)
235    } else {
236        (underlying, quote)
237    };
238    if descriptor.collateral_mint.0 != collateral || descriptor.settlement_mint.0 != settlement {
239        return Err(MarketBinding("collateral/settlement mint"));
240    }
241    let expiry = descriptor
242        .expiry_ts
243        .duration_since(std::time::UNIX_EPOCH)
244        .map_err(|_| MarketBinding("expiry_ts"))?;
245    if expiry.subsec_nanos() != 0 || expiry.as_secs() > i64::MAX as u64 {
246        return Err(MarketBinding("expiry_ts"));
247    }
248    let expected_market = market_pda(
249        &Pubkey::new_from_array(expected_program),
250        &Pubkey::new_from_array(underlying),
251        &Pubkey::new_from_array(quote),
252        expiry.as_secs(),
253        descriptor.is_put,
254    )
255    .to_bytes();
256    let market = descriptor.market_pda.0;
257    if market != expected_market {
258        return Err(MarketBinding("market_pda"));
259    }
260    Ok((market, QuoteExpiry::from_unix_seconds(expiry.as_secs())))
261}
262
263pub(crate) fn market_pda(
264    program_id: &solana_pubkey::Pubkey,
265    underlying_mint: &solana_pubkey::Pubkey,
266    quote_mint: &solana_pubkey::Pubkey,
267    expiry_ts: u64,
268    is_put: bool,
269) -> solana_pubkey::Pubkey {
270    solana_pubkey::Pubkey::find_program_address(
271        &[
272            b"market",
273            underlying_mint.as_ref(),
274            quote_mint.as_ref(),
275            &expiry_ts.to_le_bytes(),
276            &[u8::from(is_put)],
277        ],
278        program_id,
279    )
280    .0
281}
282
283#[derive(Debug, Clone, Copy)]
284struct Resolved {
285    price: Price,
286    valid_until: QuoteExpiry,
287    nonce: Nonce,
288    maker: [u8; 32],
289}
290
291/// Accumulates the maker's side of a quote, then signs it.
292#[derive(Debug, Clone)]
293#[must_use = "a builder does nothing until it is signed"]
294pub struct QuoteBuilder<'a> {
295    binding: &'a RfqBinding,
296    terms: OptionTerms,
297    price: Option<Price>,
298    valid_until: Option<QuoteExpiry>,
299    min_valid_until: Option<QuoteExpiry>,
300    buffer_end: Option<QuoteExpiry>,
301    nonce: Option<Nonce>,
302    maker: Option<[u8; 32]>,
303}
304
305impl QuoteBuilder<'_> {
306    /// Terms this builder will bind into the quote and signed preimage.
307    #[must_use]
308    pub const fn selected_terms(&self) -> OptionTerms {
309        self.terms
310    }
311
312    /// Quote alternative terms offered by the RFQ instead of its requested terms.
313    pub const fn terms(mut self, terms: OptionTerms) -> Self {
314        self.terms = terms;
315        self
316    }
317
318    pub const fn price(mut self, price: Price) -> Self {
319        self.price = Some(price);
320        self
321    }
322
323    pub const fn valid_until(mut self, valid_until: QuoteExpiry) -> Self {
324        self.valid_until = Some(valid_until);
325        self
326    }
327
328    /// Earliest `valid_until` the venue accepts: now plus `min_quote_validity_seconds`
329    /// from `GET /api/v1/venue`.
330    pub const fn min_valid_until(mut self, min_valid_until: QuoteExpiry) -> Self {
331        self.min_valid_until = Some(min_valid_until);
332        self
333    }
334
335    /// Apply the venue's quote window at the current clock: the floor
336    /// `now + min_quote_validity_seconds`, and `valid_until − settlement_buffer_seconds`
337    /// must be after now.
338    #[cfg(feature = "http")]
339    pub fn venue(self, venue: &crate::http::Venue) -> Self {
340        self.quote_window(
341            venue.min_quote_validity_seconds,
342            venue.settlement_buffer_seconds,
343        )
344    }
345
346    /// [`Self::venue`] from the session's `Welcome` instead of HTTP.
347    pub fn welcome(self, welcome: &crate::ws::types::WelcomeData) -> Self {
348        self.quote_window(
349            welcome.min_quote_validity_seconds,
350            welcome.settlement_buffer_seconds,
351        )
352    }
353
354    fn quote_window(
355        mut self,
356        min_quote_validity_seconds: u32,
357        settlement_buffer_seconds: u32,
358    ) -> Self {
359        let after = |seconds: u32| QuoteExpiry::after(Duration::from_secs(u64::from(seconds)));
360        self.min_valid_until = after(min_quote_validity_seconds);
361        self.buffer_end = after(settlement_buffer_seconds);
362        self
363    }
364
365    pub const fn nonce(mut self, nonce: Nonce) -> Self {
366        self.nonce = Some(nonce);
367        self
368    }
369
370    /// Maker owner in the signed preimage, required even when it signs itself.
371    pub const fn maker_owner(mut self, owner: crate::Address32) -> Self {
372        self.maker = Some(*owner.as_bytes());
373        self
374    }
375
376    fn resolve(&self) -> Result<Resolved, QuoteBuildError> {
377        if !self.binding.offered_terms.contains(&self.terms) {
378            return Err(QuoteBuildError::TermsNotOffered {
379                requested: self.terms,
380                offered: self.binding.offered_terms.clone(),
381            });
382        }
383        let valid_until = self
384            .valid_until
385            .ok_or(QuoteBuildError::Missing("valid_until"))?;
386        let min_valid_until = self
387            .min_valid_until
388            .ok_or(QuoteBuildError::Missing("min_valid_until"))?;
389        if valid_until > self.binding.market_expiry {
390            return Err(QuoteBuildError::ValidUntilAfterMarketExpiry {
391                valid_until,
392                market_expiry: self.binding.market_expiry,
393            });
394        }
395        if valid_until < min_valid_until {
396            return Err(QuoteBuildError::ValidUntilTooSoon {
397                valid_until,
398                min_valid_until,
399            });
400        }
401        if let Some(buffer_end) = self.buffer_end
402            && valid_until <= buffer_end
403        {
404            return Err(QuoteBuildError::ValidUntilWithinSettlementBuffer {
405                valid_until,
406                buffer_end,
407            });
408        }
409        Ok(Resolved {
410            price: self.price.ok_or(QuoteBuildError::Missing("price"))?,
411            valid_until,
412            nonce: self.nonce.ok_or(QuoteBuildError::Missing("nonce"))?,
413            maker: self.maker.ok_or(QuoteBuildError::Missing("maker_owner"))?,
414        })
415    }
416
417    fn args_for(&self, resolved: Resolved) -> OrderPreimageArgs {
418        OrderPreimageArgs {
419            chain_id: self.binding.chain_id,
420            program_id: self.binding.program_id,
421            side: self.binding.side,
422            position_type: self.terms.position_type(),
423            market: self.binding.market,
424            strike: self.terms.strike().value(),
425            quantity: self.binding.quantity.value(),
426            gross_price: resolved.price.value(),
427            valid_until: resolved.valid_until.as_unix_seconds(),
428            maker: resolved.maker,
429            taker: self.binding.taker,
430            nonce: resolved.nonce.value(),
431        }
432    }
433
434    /// The exact preimage the server will reconstruct for this quote.
435    ///
436    /// # Errors
437    /// See [`QuoteBuildError`].
438    pub fn preimage_args(&self) -> Result<OrderPreimageArgs, QuoteBuildError> {
439        if self.terms.is_spread() {
440            return Err(crate::orders::OrderError::SpreadLayoutRequired.into());
441        }
442        Ok(self.args_for(self.resolve()?))
443    }
444
445    /// Sign with a local key.
446    ///
447    /// Reusable builder. Give each quote a fresh nonce to avoid duplicate order IDs.
448    ///
449    /// # Errors
450    /// See [`QuoteBuildError`].
451    pub fn sign<S: SignerLike + ?Sized>(
452        &self,
453        signer: &S,
454    ) -> Result<QuoteMessage, QuoteBuildError> {
455        let (resolved, order_id, signature) = self.sign_parts(signer)?;
456        Ok(self.assemble(resolved, order_id, signature))
457    }
458
459    /// Sign with a remote or hardware signer.
460    ///
461    /// The returned signature is verified against the signer's own public key
462    /// before the quote is assembled.
463    ///
464    /// # Errors
465    /// See [`QuoteBuildError`].
466    pub async fn sign_with_async_signer<S: AsyncSignerLike + ?Sized>(
467        &self,
468        signer: &S,
469    ) -> Result<QuoteMessage, QuoteBuildError> {
470        let resolved = self.resolve()?;
471        let order_id = self.order_id(resolved)?;
472        let signature = crate::orders::sign_order_id_with_async_signer(&order_id, signer).await?;
473        Ok(self.assemble(resolved, order_id, signature))
474    }
475
476    fn sign_parts<S: SignerLike + ?Sized>(
477        &self,
478        signer: &S,
479    ) -> Result<(Resolved, [u8; 32], [u8; 64]), QuoteBuildError> {
480        let resolved = self.resolve()?;
481        let order_id = self.order_id(resolved)?;
482        Ok((
483            resolved,
484            order_id,
485            sign_order_id_with_signer(&order_id, signer),
486        ))
487    }
488
489    pub fn spread_preimage_args(
490        &self,
491    ) -> Result<crate::orders::SpreadOrderPreimageArgs, QuoteBuildError> {
492        self.spread_args_for(self.resolve()?)
493    }
494
495    fn spread_args_for(
496        &self,
497        resolved: Resolved,
498    ) -> Result<crate::orders::SpreadOrderPreimageArgs, QuoteBuildError> {
499        let args = self.args_for(resolved);
500        Ok(crate::orders::SpreadOrderPreimageArgs {
501            chain_id: args.chain_id,
502            program_id: args.program_id,
503            side: args.side,
504            position_type: crate::spreads::SpreadType::try_from(u8::from(
505                self.terms.to_chain().position_type(),
506            ))?,
507            market: args.market,
508            lower_strike: self.terms.to_chain().strike().value(),
509            upper_strike: self
510                .terms
511                .upper_strike()
512                .ok_or(QuoteBuildError::MarketBinding("spread terms"))?
513                .value(),
514            quantity: args.quantity,
515            gross_price: args.gross_price,
516            valid_until: args.valid_until,
517            maker: args.maker,
518            taker: args.taker,
519            nonce: args.nonce,
520        })
521    }
522
523    fn order_id(&self, resolved: Resolved) -> Result<[u8; 32], QuoteBuildError> {
524        if self.terms.is_spread() {
525            Ok(crate::orders::compute_spread_order_id(
526                &self.spread_args_for(resolved)?,
527            )?)
528        } else {
529            Ok(compute_order_id(&self.args_for(resolved))?)
530        }
531    }
532
533    fn assemble(
534        &self,
535        resolved: Resolved,
536        order_id: [u8; 32],
537        signature: [u8; 64],
538    ) -> QuoteMessage {
539        QuoteMessage {
540            rfq_id: self.binding.rfq_id,
541            terms: self.terms,
542            side: self.binding.side,
543            price: resolved.price,
544            valid_until: resolved.valid_until,
545            nonce: resolved.nonce,
546            order_id: OrderId::new(order_id),
547            signature: encode_base58(&signature),
548        }
549    }
550}
551
552impl QuoteMessage {
553    /// Add the replaced order's ID without signing again; supports remote signers.
554    #[must_use]
555    pub fn into_replacement(self, old_order_id: OrderId) -> ReplaceQuoteMessage {
556        ReplaceQuoteMessage {
557            old_order_id,
558            rfq_id: self.rfq_id,
559            terms: self.terms,
560            side: self.side,
561            price: self.price,
562            valid_until: self.valid_until,
563            nonce: self.nonce,
564            order_id: self.order_id,
565            signature: self.signature,
566        }
567    }
568}
569
570#[cfg(test)]
571#[path = "quote_tests.rs"]
572mod tests;