Skip to main content

acta_maker_sdk/
quote.rs

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