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