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