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