Skip to main content

acta_maker_sdk/
quote.rs

1//! Correct-by-construction quote assembly.
2//!
3//! A quote is two things that must agree exactly: the signed order preimage and
4//! the [`QuoteMessage`] that carries it. They repeat `strike`, `price`,
5//! `valid_until` and `nonce`, and the `order_id` must hash precisely those
6//! values. Filling both by hand makes every field an opportunity for a silent
7//! `order_id preimage mismatch` rejection.
8//!
9//! [`RfqBinding`] decodes an RFQ's identities once and resolves its strike set;
10//! [`QuoteBuilder`] then derives the preimage and the message from a single
11//! resolved set of values, so the two cannot disagree.
12
13use crate::orders::{OrderPreimageArgs, SignerLike, compute_order_id, sign_order_id_with_signer};
14use crate::signing::{AsyncSignerLike, SigningError};
15use crate::types::ids::{Nonce, OrderId, Price, Quantity, Strike};
16use crate::types::{PositionType, QuoteExpiry};
17use crate::wire::{WireError, decode_base58_32, encode_base58};
18use crate::ws::types::{QuoteMessage, ReplaceQuoteMessage, RfqBroadcastMessage};
19use uuid::Uuid;
20
21/// A quote could not be assembled from the RFQ and the supplied values.
22#[derive(Debug, thiserror::Error)]
23#[non_exhaustive]
24pub enum QuoteBuildError {
25    #[error("rfq field `{field}` is not a valid base58 32-byte key: {source}")]
26    Identity {
27        field: &'static str,
28        #[source]
29        source: WireError,
30    },
31
32    /// The engine only admits quotes on strikes the RFQ asked for
33    /// (`RfqBroadcast.strike` plus `order_options`), so an unlisted strike is
34    /// rejected here rather than after a round trip.
35    #[error("strike {requested} is not offered by this rfq; offered: {offered:?}")]
36    StrikeNotOffered {
37        requested: Strike,
38        offered: Vec<Strike>,
39    },
40
41    #[error("quote is missing `{0}`")]
42    Missing(&'static str),
43
44    #[error("signing failed: {0}")]
45    Signing(#[from] SigningError),
46}
47
48/// An RFQ with its identities decoded and its strike set resolved.
49///
50/// Build one per RFQ and reuse it for every quote and replacement on that RFQ:
51/// the base58 decoding and strike resolution happen once.
52#[derive(Debug, Clone)]
53pub struct RfqBinding {
54    rfq_id: Uuid,
55    chain_id: u64,
56    program_id: [u8; 32],
57    market: [u8; 32],
58    taker: [u8; 32],
59    position_type: PositionType,
60    quantity: Quantity,
61    requested_strike: Strike,
62    offered_strikes: Vec<Strike>,
63}
64
65fn identity(field: &'static str, value: &str) -> Result<[u8; 32], QuoteBuildError> {
66    decode_base58_32(value).map_err(|source| QuoteBuildError::Identity { field, source })
67}
68
69impl RfqBinding {
70    /// Decode and validate an inbound [`RfqBroadcastMessage`].
71    ///
72    /// # Errors
73    /// Returns [`QuoteBuildError::Identity`] if the program, market or taker key
74    /// is not valid base58.
75    pub fn from_broadcast(rfq: &RfqBroadcastMessage) -> Result<Self, QuoteBuildError> {
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: identity("market.program_id", &rfq.market.program_id)?,
89            market: identity("market.market_pda", &rfq.market.market_pda)?,
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    /// The size the engine prices this quote against. It comes from the RFQ, not
104    /// from the maker, but it is part of the signed preimage.
105    #[must_use]
106    pub const fn quantity(&self) -> Quantity {
107        self.quantity
108    }
109
110    #[must_use]
111    pub const fn position_type(&self) -> PositionType {
112        self.position_type
113    }
114
115    /// Every strike this RFQ will accept a quote on.
116    #[must_use]
117    pub fn offered_strikes(&self) -> &[Strike] {
118        &self.offered_strikes
119    }
120
121    /// Start a quote on this RFQ's requested strike.
122    pub const fn quote(&self) -> QuoteBuilder<'_> {
123        QuoteBuilder {
124            binding: self,
125            strike: self.requested_strike,
126            price: None,
127            valid_until: None,
128            nonce: None,
129            maker: None,
130        }
131    }
132}
133
134/// The maker-supplied half of a quote, after validation.
135#[derive(Debug, Clone, Copy)]
136struct Resolved {
137    strike: Strike,
138    price: Price,
139    valid_until: QuoteExpiry,
140    nonce: Nonce,
141}
142
143/// Accumulates the maker's side of a quote, then signs it.
144#[derive(Debug, Clone)]
145#[must_use = "a builder does nothing until it is signed"]
146pub struct QuoteBuilder<'a> {
147    binding: &'a RfqBinding,
148    strike: Strike,
149    price: Option<Price>,
150    valid_until: Option<QuoteExpiry>,
151    nonce: Option<Nonce>,
152    maker: Option<[u8; 32]>,
153}
154
155impl QuoteBuilder<'_> {
156    /// Quote an alternative strike offered by the RFQ instead of its requested one.
157    pub const fn strike(mut self, strike: Strike) -> Self {
158        self.strike = strike;
159        self
160    }
161
162    pub const fn price(mut self, price: Price) -> Self {
163        self.price = Some(price);
164        self
165    }
166
167    pub const fn valid_until(mut self, valid_until: QuoteExpiry) -> Self {
168        self.valid_until = Some(valid_until);
169        self
170    }
171
172    pub const fn nonce(mut self, nonce: Nonce) -> Self {
173        self.nonce = Some(nonce);
174        self
175    }
176
177    /// The maker identity signed into the order preimage.
178    ///
179    /// The server reconstructs the preimage with the session's authenticated
180    /// maker owner. When quotes are signed by a delegated key, set the owner
181    /// here or every quote is rejected as `order_id preimage mismatch`.
182    /// Defaults to the signing key's own public key.
183    pub const fn maker_owner(mut self, owner: [u8; 32]) -> Self {
184        self.maker = Some(owner);
185        self
186    }
187
188    fn resolve(&self) -> Result<Resolved, QuoteBuildError> {
189        if !self.binding.offered_strikes.contains(&self.strike) {
190            return Err(QuoteBuildError::StrikeNotOffered {
191                requested: self.strike,
192                offered: self.binding.offered_strikes.clone(),
193            });
194        }
195        Ok(Resolved {
196            strike: self.strike,
197            price: self.price.ok_or(QuoteBuildError::Missing("price"))?,
198            valid_until: self
199                .valid_until
200                .ok_or(QuoteBuildError::Missing("valid_until"))?,
201            nonce: self.nonce.ok_or(QuoteBuildError::Missing("nonce"))?,
202        })
203    }
204
205    const fn maker_for(&self, signer_pubkey: [u8; 32]) -> [u8; 32] {
206        match self.maker {
207            Some(owner) => owner,
208            None => signer_pubkey,
209        }
210    }
211
212    fn args_for(&self, resolved: Resolved, maker: [u8; 32]) -> OrderPreimageArgs {
213        OrderPreimageArgs {
214            chain_id: self.binding.chain_id,
215            program_id: self.binding.program_id,
216            // Maker-quoted RFQs are always sells from the maker's side.
217            is_taker_buy: false,
218            position_type: self.binding.position_type,
219            market: self.binding.market,
220            strike: resolved.strike.value(),
221            quantity: self.binding.quantity.value(),
222            gross_price: resolved.price.value(),
223            valid_until: resolved.valid_until.as_unix_seconds(),
224            maker,
225            taker: self.binding.taker,
226            nonce: resolved.nonce.value(),
227        }
228    }
229
230    /// The exact preimage the server will reconstruct for this quote.
231    ///
232    /// Exposed so a maker can inspect the bytes or sign them out of band.
233    /// `maker` is the signing key's fallback identity; an owner set with
234    /// [`Self::maker_owner`] wins, matching the `sign` paths.
235    ///
236    /// # Errors
237    /// See [`QuoteBuildError`].
238    pub fn preimage_args(&self, maker: [u8; 32]) -> Result<OrderPreimageArgs, QuoteBuildError> {
239        let resolved = self.resolve()?;
240        Ok(self.args_for(resolved, self.maker_for(maker)))
241    }
242
243    /// Sign with a local key.
244    ///
245    /// The builder is borrowed, so it can be signed more than once — useful for
246    /// quoting several strikes off one binding. Give each quote its own nonce:
247    /// signing twice with the same one reproduces the same `order_id`, which
248    /// the server rejects as a duplicate.
249    ///
250    /// # Errors
251    /// See [`QuoteBuildError`].
252    pub fn sign<S: SignerLike>(&self, signer: &S) -> Result<QuoteMessage, QuoteBuildError> {
253        let (resolved, order_id, signature) = self.sign_parts(signer)?;
254        Ok(self.assemble(resolved, order_id, signature))
255    }
256
257    /// Sign with a remote or hardware signer.
258    ///
259    /// The returned signature is verified against the signer's own public key
260    /// before the quote is assembled.
261    ///
262    /// # Errors
263    /// See [`QuoteBuildError`].
264    pub async fn sign_with_async_signer<S: AsyncSignerLike + ?Sized>(
265        &self,
266        signer: &S,
267    ) -> Result<QuoteMessage, QuoteBuildError> {
268        let resolved = self.resolve()?;
269        let args = self.args_for(resolved, self.maker_for(signer.pubkey_bytes()));
270        let order_id = compute_order_id(&args);
271        let signature = crate::orders::sign_order_id_with_async_signer(&order_id, signer).await?;
272        Ok(self.assemble(resolved, order_id, signature))
273    }
274
275    fn sign_parts<S: SignerLike>(
276        &self,
277        signer: &S,
278    ) -> Result<(Resolved, [u8; 32], [u8; 64]), QuoteBuildError> {
279        let resolved = self.resolve()?;
280        let args = self.args_for(resolved, self.maker_for(signer.pubkey_bytes()));
281        let order_id = compute_order_id(&args);
282        Ok((
283            resolved,
284            order_id,
285            sign_order_id_with_signer(&order_id, signer),
286        ))
287    }
288
289    /// Assemble the message from the same `resolved` values the preimage used.
290    /// This is the only place a `QuoteMessage` is built, so the wire fields and
291    /// the signed fields are the same values by construction.
292    fn assemble(
293        &self,
294        resolved: Resolved,
295        order_id: [u8; 32],
296        signature: [u8; 64],
297    ) -> QuoteMessage {
298        QuoteMessage {
299            rfq_id: self.binding.rfq_id,
300            strike: resolved.strike,
301            price: resolved.price,
302            valid_until: resolved.valid_until,
303            nonce: resolved.nonce,
304            order_id: OrderId::new(order_id),
305            signature: encode_base58(&signature),
306        }
307    }
308}
309
310impl QuoteMessage {
311    /// Turn this signed quote into a replacement for an earlier order.
312    ///
313    /// A replacement is the same signed quote plus the order it supersedes, so
314    /// it is a conversion rather than a second signing path — which keeps it
315    /// available to remote signers too.
316    #[must_use]
317    pub fn into_replacement(self, old_order_id: OrderId) -> ReplaceQuoteMessage {
318        ReplaceQuoteMessage {
319            old_order_id,
320            rfq_id: self.rfq_id,
321            strike: self.strike,
322            price: self.price,
323            valid_until: self.valid_until,
324            nonce: self.nonce,
325            order_id: self.order_id,
326            signature: self.signature,
327        }
328    }
329}
330
331#[cfg(test)]
332#[path = "quote_tests.rs"]
333mod tests;