1use 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#[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 #[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#[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 pub fn from_broadcast(rfq: &RfqBroadcastMessage) -> Result<Self, QuoteBuildError> {
81 Self::from_broadcast_for_program(rfq, crate::ACTA_PROGRAM_ID)
82 }
83
84 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 #[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 #[must_use]
134 pub const fn market_expiry(&self) -> QuoteExpiry {
135 self.market_expiry
136 }
137
138 #[must_use]
140 pub fn offered_strikes(&self) -> &[Strike] {
141 &self.offered_strikes
142 }
143
144 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#[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 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 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 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 pub fn preimage_args(&self) -> Result<OrderPreimageArgs, QuoteBuildError> {
342 Ok(self.args_for(self.resolve()?))
343 }
344
345 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 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 #[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;