Skip to main content

acta_maker_sdk/
quote.rs

1//! Quote assembly: [`RfqBinding`] decodes identities and resolves offered strikes;
2//! [`QuoteBuilder`] derives the signed preimage and [`QuoteMessage`] from the
3//! same strike, 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, Strike};
8use crate::types::{PositionType, QuoteExpiry};
9use crate::wire::{WireError, decode_base58_32, 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("rfq field `{field}` is not a valid base58 32-byte key: {source}")]
19    Identity {
20        field: &'static str,
21        #[source]
22        source: WireError,
23    },
24
25    #[error("rfq market does not match trusted signing terms: {0}")]
26    MarketBinding(&'static str),
27
28    /// Strike absent from `RfqBroadcast.strike` and `order_options`.
29    #[error("strike {requested} is not offered by this rfq; offered: {offered:?}")]
30    StrikeNotOffered {
31        requested: Strike,
32        offered: Vec<Strike>,
33    },
34
35    #[error("quote is missing `{0}`")]
36    Missing(&'static str),
37
38    #[error("valid_until {valid_until} is after market expiry {market_expiry}")]
39    ValidUntilAfterMarketExpiry {
40        valid_until: QuoteExpiry,
41        market_expiry: QuoteExpiry,
42    },
43
44    #[error("valid_until {valid_until} is before the venue minimum {min_valid_until}")]
45    ValidUntilTooSoon {
46        valid_until: QuoteExpiry,
47        min_valid_until: QuoteExpiry,
48    },
49
50    /// `valid_until − settlement_buffer_seconds` is not after now, so the
51    /// quote could never be accepted.
52    #[error("valid_until {valid_until} is within the settlement buffer ending at {buffer_end}")]
53    ValidUntilWithinSettlementBuffer {
54        valid_until: QuoteExpiry,
55        buffer_end: QuoteExpiry,
56    },
57
58    #[error("signing failed: {0}")]
59    Signing(#[from] SigningError),
60}
61
62/// An RFQ with its identities decoded and its strike set resolved.
63///
64/// Reuse per RFQ to avoid repeated decoding and strike resolution.
65#[derive(Debug, Clone)]
66pub struct RfqBinding {
67    rfq_id: Uuid,
68    chain_id: u64,
69    program_id: [u8; 32],
70    market: [u8; 32],
71    market_expiry: QuoteExpiry,
72    taker: [u8; 32],
73    position_type: PositionType,
74    quantity: Quantity,
75    requested_strike: Strike,
76    offered_strikes: Vec<Strike>,
77}
78
79fn identity(field: &'static str, value: &str) -> Result<[u8; 32], QuoteBuildError> {
80    decode_base58_32(value).map_err(|source| QuoteBuildError::Identity { field, source })
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 (market, market_expiry) = validate_market_binding(rfq, expected_program)?;
102        let mut offered_strikes = Vec::with_capacity(1 + rfq.order_options.len());
103        offered_strikes.push(rfq.strike);
104        offered_strikes.extend(
105            rfq.order_options
106                .iter()
107                .map(|option| option.strike)
108                .filter(|strike| *strike != rfq.strike),
109        );
110
111        Ok(Self {
112            rfq_id: rfq.rfq_id,
113            chain_id: rfq.market.chain_id.value(),
114            program_id: expected_program,
115            market,
116            market_expiry,
117            taker: identity("taker", &rfq.taker)?,
118            position_type: rfq.position_type,
119            quantity: rfq.quantity,
120            requested_strike: rfq.strike,
121            offered_strikes,
122        })
123    }
124
125    #[must_use]
126    pub const fn rfq_id(&self) -> Uuid {
127        self.rfq_id
128    }
129
130    /// RFQ quantity used for pricing and included in the signed preimage.
131    #[must_use]
132    pub const fn quantity(&self) -> Quantity {
133        self.quantity
134    }
135
136    #[must_use]
137    pub const fn position_type(&self) -> PositionType {
138        self.position_type
139    }
140
141    /// Latest `valid_until` the venue accepts for this RFQ.
142    #[must_use]
143    pub const fn market_expiry(&self) -> QuoteExpiry {
144        self.market_expiry
145    }
146
147    /// Every strike this RFQ will accept a quote on.
148    #[must_use]
149    pub fn offered_strikes(&self) -> &[Strike] {
150        &self.offered_strikes
151    }
152
153    /// Start a quote on this RFQ's requested strike.
154    pub const fn quote(&self) -> QuoteBuilder<'_> {
155        QuoteBuilder {
156            binding: self,
157            strike: self.requested_strike,
158            price: None,
159            valid_until: None,
160            min_valid_until: None,
161            buffer_end: None,
162            nonce: None,
163            maker: None,
164        }
165    }
166}
167
168fn validate_market_binding(
169    rfq: &RfqBroadcastMessage,
170    expected_program: [u8; 32],
171) -> Result<([u8; 32], QuoteExpiry), QuoteBuildError> {
172    use QuoteBuildError::MarketBinding;
173    use solana_pubkey::Pubkey;
174
175    let descriptor = &rfq.market;
176    if descriptor.chain_id.value() != 0 {
177        return Err(MarketBinding("chain_id"));
178    }
179    if identity("market.program_id", &descriptor.program_id)? != expected_program {
180        return Err(MarketBinding("program_id"));
181    }
182    if descriptor.is_put != (rfq.position_type == PositionType::CashSecuredPut) {
183        return Err(MarketBinding("position_type"));
184    }
185    let underlying = identity("market.underlying_mint", &descriptor.underlying_mint)?;
186    let quote = identity("market.quote_mint", &descriptor.quote_mint)?;
187    let (collateral, settlement) = if descriptor.is_put {
188        (quote, underlying)
189    } else {
190        (underlying, quote)
191    };
192    if identity("market.collateral_mint", &descriptor.collateral_mint)? != collateral
193        || identity("market.settlement_mint", &descriptor.settlement_mint)? != settlement
194    {
195        return Err(MarketBinding("collateral/settlement mint"));
196    }
197    let expiry = descriptor
198        .expiry_ts
199        .duration_since(std::time::UNIX_EPOCH)
200        .map_err(|_| MarketBinding("expiry_ts"))?;
201    if expiry.subsec_nanos() != 0 || expiry.as_secs() > i64::MAX as u64 {
202        return Err(MarketBinding("expiry_ts"));
203    }
204    let expected_market = market_pda(
205        &Pubkey::new_from_array(expected_program),
206        &Pubkey::new_from_array(underlying),
207        &Pubkey::new_from_array(quote),
208        expiry.as_secs(),
209        descriptor.is_put,
210    )
211    .to_bytes();
212    let market = identity("market.market_pda", &descriptor.market_pda)?;
213    if market != expected_market {
214        return Err(MarketBinding("market_pda"));
215    }
216    Ok((market, QuoteExpiry::from_unix_seconds(expiry.as_secs())))
217}
218
219pub(crate) fn market_pda(
220    program_id: &solana_pubkey::Pubkey,
221    underlying_mint: &solana_pubkey::Pubkey,
222    quote_mint: &solana_pubkey::Pubkey,
223    expiry_ts: u64,
224    is_put: bool,
225) -> solana_pubkey::Pubkey {
226    solana_pubkey::Pubkey::find_program_address(
227        &[
228            b"market",
229            underlying_mint.as_ref(),
230            quote_mint.as_ref(),
231            &expiry_ts.to_le_bytes(),
232            &[u8::from(is_put)],
233        ],
234        program_id,
235    )
236    .0
237}
238
239#[derive(Debug, Clone, Copy)]
240struct Resolved {
241    strike: Strike,
242    price: Price,
243    valid_until: QuoteExpiry,
244    nonce: Nonce,
245    maker: [u8; 32],
246}
247
248/// Accumulates the maker's side of a quote, then signs it.
249#[derive(Debug, Clone)]
250#[must_use = "a builder does nothing until it is signed"]
251pub struct QuoteBuilder<'a> {
252    binding: &'a RfqBinding,
253    strike: Strike,
254    price: Option<Price>,
255    valid_until: Option<QuoteExpiry>,
256    min_valid_until: Option<QuoteExpiry>,
257    buffer_end: Option<QuoteExpiry>,
258    nonce: Option<Nonce>,
259    maker: Option<[u8; 32]>,
260}
261
262impl QuoteBuilder<'_> {
263    /// Quote an alternative strike offered by the RFQ instead of its requested one.
264    pub const fn strike(mut self, strike: Strike) -> Self {
265        self.strike = strike;
266        self
267    }
268
269    pub const fn price(mut self, price: Price) -> Self {
270        self.price = Some(price);
271        self
272    }
273
274    pub const fn valid_until(mut self, valid_until: QuoteExpiry) -> Self {
275        self.valid_until = Some(valid_until);
276        self
277    }
278
279    /// Earliest `valid_until` the venue accepts: now plus `min_quote_validity_seconds`
280    /// from `GET /api/v1/venue`.
281    pub const fn min_valid_until(mut self, min_valid_until: QuoteExpiry) -> Self {
282        self.min_valid_until = Some(min_valid_until);
283        self
284    }
285
286    /// Apply the venue's quote window at the current clock: the floor
287    /// `now + min_quote_validity_seconds`, and `valid_until − settlement_buffer_seconds`
288    /// must be after now.
289    #[cfg(feature = "http")]
290    pub fn venue(self, venue: &crate::http::Venue) -> Self {
291        self.quote_window(
292            venue.min_quote_validity_seconds,
293            venue.settlement_buffer_seconds,
294        )
295    }
296
297    /// [`Self::venue`] from the session's `Welcome` instead of HTTP.
298    pub fn welcome(self, welcome: &crate::ws::types::WelcomeData) -> Self {
299        self.quote_window(
300            welcome.min_quote_validity_seconds,
301            welcome.settlement_buffer_seconds,
302        )
303    }
304
305    fn quote_window(
306        mut self,
307        min_quote_validity_seconds: u32,
308        settlement_buffer_seconds: u32,
309    ) -> Self {
310        let after = |seconds: u32| QuoteExpiry::after(Duration::from_secs(u64::from(seconds)));
311        self.min_valid_until = after(min_quote_validity_seconds);
312        self.buffer_end = after(settlement_buffer_seconds);
313        self
314    }
315
316    pub const fn nonce(mut self, nonce: Nonce) -> Self {
317        self.nonce = Some(nonce);
318        self
319    }
320
321    /// Maker owner in the signed preimage, required even when it signs itself.
322    pub const fn maker_owner(mut self, owner: [u8; 32]) -> Self {
323        self.maker = Some(owner);
324        self
325    }
326
327    fn resolve(&self) -> Result<Resolved, QuoteBuildError> {
328        if !self.binding.offered_strikes.contains(&self.strike) {
329            return Err(QuoteBuildError::StrikeNotOffered {
330                requested: self.strike,
331                offered: self.binding.offered_strikes.clone(),
332            });
333        }
334        let valid_until = self
335            .valid_until
336            .ok_or(QuoteBuildError::Missing("valid_until"))?;
337        let min_valid_until = self
338            .min_valid_until
339            .ok_or(QuoteBuildError::Missing("min_valid_until"))?;
340        if valid_until > self.binding.market_expiry {
341            return Err(QuoteBuildError::ValidUntilAfterMarketExpiry {
342                valid_until,
343                market_expiry: self.binding.market_expiry,
344            });
345        }
346        if valid_until < min_valid_until {
347            return Err(QuoteBuildError::ValidUntilTooSoon {
348                valid_until,
349                min_valid_until,
350            });
351        }
352        if let Some(buffer_end) = self.buffer_end
353            && valid_until <= buffer_end
354        {
355            return Err(QuoteBuildError::ValidUntilWithinSettlementBuffer {
356                valid_until,
357                buffer_end,
358            });
359        }
360        Ok(Resolved {
361            strike: self.strike,
362            price: self.price.ok_or(QuoteBuildError::Missing("price"))?,
363            valid_until,
364            nonce: self.nonce.ok_or(QuoteBuildError::Missing("nonce"))?,
365            maker: self.maker.ok_or(QuoteBuildError::Missing("maker_owner"))?,
366        })
367    }
368
369    fn args_for(&self, resolved: Resolved) -> OrderPreimageArgs {
370        OrderPreimageArgs {
371            chain_id: self.binding.chain_id,
372            program_id: self.binding.program_id,
373            is_taker_buy: false,
374            position_type: self.binding.position_type,
375            market: self.binding.market,
376            strike: resolved.strike.value(),
377            quantity: self.binding.quantity.value(),
378            gross_price: resolved.price.value(),
379            valid_until: resolved.valid_until.as_unix_seconds(),
380            maker: resolved.maker,
381            taker: self.binding.taker,
382            nonce: resolved.nonce.value(),
383        }
384    }
385
386    /// The exact preimage the server will reconstruct for this quote.
387    ///
388    /// # Errors
389    /// See [`QuoteBuildError`].
390    pub fn preimage_args(&self) -> Result<OrderPreimageArgs, QuoteBuildError> {
391        Ok(self.args_for(self.resolve()?))
392    }
393
394    /// Sign with a local key.
395    ///
396    /// Reusable builder. Give each quote a fresh nonce to avoid duplicate order IDs.
397    ///
398    /// # Errors
399    /// See [`QuoteBuildError`].
400    pub fn sign<S: SignerLike + ?Sized>(
401        &self,
402        signer: &S,
403    ) -> Result<QuoteMessage, QuoteBuildError> {
404        let (resolved, order_id, signature) = self.sign_parts(signer)?;
405        Ok(self.assemble(resolved, order_id, signature))
406    }
407
408    /// Sign with a remote or hardware signer.
409    ///
410    /// The returned signature is verified against the signer's own public key
411    /// before the quote is assembled.
412    ///
413    /// # Errors
414    /// See [`QuoteBuildError`].
415    pub async fn sign_with_async_signer<S: AsyncSignerLike + ?Sized>(
416        &self,
417        signer: &S,
418    ) -> Result<QuoteMessage, QuoteBuildError> {
419        let resolved = self.resolve()?;
420        let order_id = compute_order_id(&self.args_for(resolved));
421        let signature = crate::orders::sign_order_id_with_async_signer(&order_id, signer).await?;
422        Ok(self.assemble(resolved, order_id, signature))
423    }
424
425    fn sign_parts<S: SignerLike + ?Sized>(
426        &self,
427        signer: &S,
428    ) -> Result<(Resolved, [u8; 32], [u8; 64]), QuoteBuildError> {
429        let resolved = self.resolve()?;
430        let order_id = compute_order_id(&self.args_for(resolved));
431        Ok((
432            resolved,
433            order_id,
434            sign_order_id_with_signer(&order_id, signer),
435        ))
436    }
437
438    fn assemble(
439        &self,
440        resolved: Resolved,
441        order_id: [u8; 32],
442        signature: [u8; 64],
443    ) -> QuoteMessage {
444        QuoteMessage {
445            rfq_id: self.binding.rfq_id,
446            strike: resolved.strike,
447            price: resolved.price,
448            valid_until: resolved.valid_until,
449            nonce: resolved.nonce,
450            order_id: OrderId::new(order_id),
451            signature: encode_base58(&signature),
452        }
453    }
454}
455
456impl QuoteMessage {
457    /// Add the replaced order's ID without signing again; supports remote signers.
458    #[must_use]
459    pub fn into_replacement(self, old_order_id: OrderId) -> ReplaceQuoteMessage {
460        ReplaceQuoteMessage {
461            old_order_id,
462            rfq_id: self.rfq_id,
463            strike: self.strike,
464            price: self.price,
465            valid_until: self.valid_until,
466            nonce: self.nonce,
467            order_id: self.order_id,
468            signature: self.signature,
469        }
470    }
471}
472
473#[cfg(test)]
474#[path = "quote_tests.rs"]
475mod tests;