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