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 std::time::Duration;
12use uuid::Uuid;
13
14#[derive(Debug, thiserror::Error)]
16#[non_exhaustive]
17pub enum QuoteBuildError {
18 #[error("rfq field `{field}` is not a valid base58 32-byte key: {source}")]
19 Identity {
20 field: &'static str,
21 #[source]
22 source: WireError,
23 },
24
25 #[error("rfq market does not match trusted signing terms: {0}")]
26 MarketBinding(&'static str),
27
28 #[error("strike {requested} is not offered by this rfq; offered: {offered:?}")]
30 StrikeNotOffered {
31 requested: Strike,
32 offered: Vec<Strike>,
33 },
34
35 #[error("quote is missing `{0}`")]
36 Missing(&'static str),
37
38 #[error("valid_until {valid_until} is after market expiry {market_expiry}")]
39 ValidUntilAfterMarketExpiry {
40 valid_until: QuoteExpiry,
41 market_expiry: QuoteExpiry,
42 },
43
44 #[error("valid_until {valid_until} is before the venue minimum {min_valid_until}")]
45 ValidUntilTooSoon {
46 valid_until: QuoteExpiry,
47 min_valid_until: QuoteExpiry,
48 },
49
50 #[error("valid_until {valid_until} is within the settlement buffer ending at {buffer_end}")]
53 ValidUntilWithinSettlementBuffer {
54 valid_until: QuoteExpiry,
55 buffer_end: QuoteExpiry,
56 },
57
58 #[error("signing failed: {0}")]
59 Signing(#[from] SigningError),
60}
61
62#[derive(Debug, Clone)]
66pub struct RfqBinding {
67 rfq_id: Uuid,
68 chain_id: u64,
69 program_id: [u8; 32],
70 market: [u8; 32],
71 market_expiry: QuoteExpiry,
72 taker: [u8; 32],
73 position_type: PositionType,
74 quantity: Quantity,
75 requested_strike: Strike,
76 offered_strikes: Vec<Strike>,
77}
78
79fn identity(field: &'static str, value: &str) -> Result<[u8; 32], QuoteBuildError> {
80 decode_base58_32(value).map_err(|source| QuoteBuildError::Identity { field, source })
81}
82
83impl RfqBinding {
84 pub fn from_broadcast(rfq: &RfqBroadcastMessage) -> Result<Self, QuoteBuildError> {
90 Self::from_broadcast_for_program(rfq, crate::ACTA_PROGRAM_ID)
91 }
92
93 pub fn from_broadcast_for_program(
98 rfq: &RfqBroadcastMessage,
99 expected_program: [u8; 32],
100 ) -> Result<Self, QuoteBuildError> {
101 let (market, market_expiry) = validate_market_binding(rfq, expected_program)?;
102 let mut offered_strikes = Vec::with_capacity(1 + rfq.order_options.len());
103 offered_strikes.push(rfq.strike);
104 offered_strikes.extend(
105 rfq.order_options
106 .iter()
107 .map(|option| option.strike)
108 .filter(|strike| *strike != rfq.strike),
109 );
110
111 Ok(Self {
112 rfq_id: rfq.rfq_id,
113 chain_id: rfq.market.chain_id.value(),
114 program_id: expected_program,
115 market,
116 market_expiry,
117 taker: identity("taker", &rfq.taker)?,
118 position_type: rfq.position_type,
119 quantity: rfq.quantity,
120 requested_strike: rfq.strike,
121 offered_strikes,
122 })
123 }
124
125 #[must_use]
126 pub const fn rfq_id(&self) -> Uuid {
127 self.rfq_id
128 }
129
130 #[must_use]
132 pub const fn quantity(&self) -> Quantity {
133 self.quantity
134 }
135
136 #[must_use]
137 pub const fn position_type(&self) -> PositionType {
138 self.position_type
139 }
140
141 #[must_use]
143 pub const fn market_expiry(&self) -> QuoteExpiry {
144 self.market_expiry
145 }
146
147 #[must_use]
149 pub fn offered_strikes(&self) -> &[Strike] {
150 &self.offered_strikes
151 }
152
153 pub const fn quote(&self) -> QuoteBuilder<'_> {
155 QuoteBuilder {
156 binding: self,
157 strike: self.requested_strike,
158 price: None,
159 valid_until: None,
160 min_valid_until: None,
161 buffer_end: None,
162 nonce: None,
163 maker: None,
164 }
165 }
166}
167
168fn validate_market_binding(
169 rfq: &RfqBroadcastMessage,
170 expected_program: [u8; 32],
171) -> Result<([u8; 32], QuoteExpiry), QuoteBuildError> {
172 use QuoteBuildError::MarketBinding;
173 use solana_pubkey::Pubkey;
174
175 let descriptor = &rfq.market;
176 if descriptor.chain_id.value() != 0 {
177 return Err(MarketBinding("chain_id"));
178 }
179 if identity("market.program_id", &descriptor.program_id)? != expected_program {
180 return Err(MarketBinding("program_id"));
181 }
182 if descriptor.is_put != (rfq.position_type == PositionType::CashSecuredPut) {
183 return Err(MarketBinding("position_type"));
184 }
185 let underlying = identity("market.underlying_mint", &descriptor.underlying_mint)?;
186 let quote = identity("market.quote_mint", &descriptor.quote_mint)?;
187 let (collateral, settlement) = if descriptor.is_put {
188 (quote, underlying)
189 } else {
190 (underlying, quote)
191 };
192 if identity("market.collateral_mint", &descriptor.collateral_mint)? != collateral
193 || identity("market.settlement_mint", &descriptor.settlement_mint)? != settlement
194 {
195 return Err(MarketBinding("collateral/settlement mint"));
196 }
197 let expiry = descriptor
198 .expiry_ts
199 .duration_since(std::time::UNIX_EPOCH)
200 .map_err(|_| MarketBinding("expiry_ts"))?;
201 if expiry.subsec_nanos() != 0 || expiry.as_secs() > i64::MAX as u64 {
202 return Err(MarketBinding("expiry_ts"));
203 }
204 let expected_market = market_pda(
205 &Pubkey::new_from_array(expected_program),
206 &Pubkey::new_from_array(underlying),
207 &Pubkey::new_from_array(quote),
208 expiry.as_secs(),
209 descriptor.is_put,
210 )
211 .to_bytes();
212 let market = identity("market.market_pda", &descriptor.market_pda)?;
213 if market != expected_market {
214 return Err(MarketBinding("market_pda"));
215 }
216 Ok((market, QuoteExpiry::from_unix_seconds(expiry.as_secs())))
217}
218
219pub(crate) fn market_pda(
220 program_id: &solana_pubkey::Pubkey,
221 underlying_mint: &solana_pubkey::Pubkey,
222 quote_mint: &solana_pubkey::Pubkey,
223 expiry_ts: u64,
224 is_put: bool,
225) -> solana_pubkey::Pubkey {
226 solana_pubkey::Pubkey::find_program_address(
227 &[
228 b"market",
229 underlying_mint.as_ref(),
230 quote_mint.as_ref(),
231 &expiry_ts.to_le_bytes(),
232 &[u8::from(is_put)],
233 ],
234 program_id,
235 )
236 .0
237}
238
239#[derive(Debug, Clone, Copy)]
240struct Resolved {
241 strike: Strike,
242 price: Price,
243 valid_until: QuoteExpiry,
244 nonce: Nonce,
245 maker: [u8; 32],
246}
247
248#[derive(Debug, Clone)]
250#[must_use = "a builder does nothing until it is signed"]
251pub struct QuoteBuilder<'a> {
252 binding: &'a RfqBinding,
253 strike: Strike,
254 price: Option<Price>,
255 valid_until: Option<QuoteExpiry>,
256 min_valid_until: Option<QuoteExpiry>,
257 buffer_end: Option<QuoteExpiry>,
258 nonce: Option<Nonce>,
259 maker: Option<[u8; 32]>,
260}
261
262impl QuoteBuilder<'_> {
263 pub const fn strike(mut self, strike: Strike) -> Self {
265 self.strike = strike;
266 self
267 }
268
269 pub const fn price(mut self, price: Price) -> Self {
270 self.price = Some(price);
271 self
272 }
273
274 pub const fn valid_until(mut self, valid_until: QuoteExpiry) -> Self {
275 self.valid_until = Some(valid_until);
276 self
277 }
278
279 pub const fn min_valid_until(mut self, min_valid_until: QuoteExpiry) -> Self {
282 self.min_valid_until = Some(min_valid_until);
283 self
284 }
285
286 #[cfg(feature = "http")]
290 pub fn venue(self, venue: &crate::http::Venue) -> Self {
291 self.quote_window(
292 venue.min_quote_validity_seconds,
293 venue.settlement_buffer_seconds,
294 )
295 }
296
297 pub fn welcome(self, welcome: &crate::ws::types::WelcomeData) -> Self {
299 self.quote_window(
300 welcome.min_quote_validity_seconds,
301 welcome.settlement_buffer_seconds,
302 )
303 }
304
305 fn quote_window(
306 mut self,
307 min_quote_validity_seconds: u32,
308 settlement_buffer_seconds: u32,
309 ) -> Self {
310 let after = |seconds: u32| QuoteExpiry::after(Duration::from_secs(u64::from(seconds)));
311 self.min_valid_until = after(min_quote_validity_seconds);
312 self.buffer_end = after(settlement_buffer_seconds);
313 self
314 }
315
316 pub const fn nonce(mut self, nonce: Nonce) -> Self {
317 self.nonce = Some(nonce);
318 self
319 }
320
321 pub const fn maker_owner(mut self, owner: [u8; 32]) -> Self {
323 self.maker = Some(owner);
324 self
325 }
326
327 fn resolve(&self) -> Result<Resolved, QuoteBuildError> {
328 if !self.binding.offered_strikes.contains(&self.strike) {
329 return Err(QuoteBuildError::StrikeNotOffered {
330 requested: self.strike,
331 offered: self.binding.offered_strikes.clone(),
332 });
333 }
334 let valid_until = self
335 .valid_until
336 .ok_or(QuoteBuildError::Missing("valid_until"))?;
337 let min_valid_until = self
338 .min_valid_until
339 .ok_or(QuoteBuildError::Missing("min_valid_until"))?;
340 if valid_until > self.binding.market_expiry {
341 return Err(QuoteBuildError::ValidUntilAfterMarketExpiry {
342 valid_until,
343 market_expiry: self.binding.market_expiry,
344 });
345 }
346 if valid_until < min_valid_until {
347 return Err(QuoteBuildError::ValidUntilTooSoon {
348 valid_until,
349 min_valid_until,
350 });
351 }
352 if let Some(buffer_end) = self.buffer_end
353 && valid_until <= buffer_end
354 {
355 return Err(QuoteBuildError::ValidUntilWithinSettlementBuffer {
356 valid_until,
357 buffer_end,
358 });
359 }
360 Ok(Resolved {
361 strike: self.strike,
362 price: self.price.ok_or(QuoteBuildError::Missing("price"))?,
363 valid_until,
364 nonce: self.nonce.ok_or(QuoteBuildError::Missing("nonce"))?,
365 maker: self.maker.ok_or(QuoteBuildError::Missing("maker_owner"))?,
366 })
367 }
368
369 fn args_for(&self, resolved: Resolved) -> OrderPreimageArgs {
370 OrderPreimageArgs {
371 chain_id: self.binding.chain_id,
372 program_id: self.binding.program_id,
373 is_taker_buy: false,
374 position_type: self.binding.position_type,
375 market: self.binding.market,
376 strike: resolved.strike.value(),
377 quantity: self.binding.quantity.value(),
378 gross_price: resolved.price.value(),
379 valid_until: resolved.valid_until.as_unix_seconds(),
380 maker: resolved.maker,
381 taker: self.binding.taker,
382 nonce: resolved.nonce.value(),
383 }
384 }
385
386 pub fn preimage_args(&self) -> Result<OrderPreimageArgs, QuoteBuildError> {
391 Ok(self.args_for(self.resolve()?))
392 }
393
394 pub fn sign<S: SignerLike + ?Sized>(
401 &self,
402 signer: &S,
403 ) -> Result<QuoteMessage, QuoteBuildError> {
404 let (resolved, order_id, signature) = self.sign_parts(signer)?;
405 Ok(self.assemble(resolved, order_id, signature))
406 }
407
408 pub async fn sign_with_async_signer<S: AsyncSignerLike + ?Sized>(
416 &self,
417 signer: &S,
418 ) -> Result<QuoteMessage, QuoteBuildError> {
419 let resolved = self.resolve()?;
420 let order_id = compute_order_id(&self.args_for(resolved));
421 let signature = crate::orders::sign_order_id_with_async_signer(&order_id, signer).await?;
422 Ok(self.assemble(resolved, order_id, signature))
423 }
424
425 fn sign_parts<S: SignerLike + ?Sized>(
426 &self,
427 signer: &S,
428 ) -> Result<(Resolved, [u8; 32], [u8; 64]), QuoteBuildError> {
429 let resolved = self.resolve()?;
430 let order_id = compute_order_id(&self.args_for(resolved));
431 Ok((
432 resolved,
433 order_id,
434 sign_order_id_with_signer(&order_id, signer),
435 ))
436 }
437
438 fn assemble(
439 &self,
440 resolved: Resolved,
441 order_id: [u8; 32],
442 signature: [u8; 64],
443 ) -> QuoteMessage {
444 QuoteMessage {
445 rfq_id: self.binding.rfq_id,
446 strike: resolved.strike,
447 price: resolved.price,
448 valid_until: resolved.valid_until,
449 nonce: resolved.nonce,
450 order_id: OrderId::new(order_id),
451 signature: encode_base58(&signature),
452 }
453 }
454}
455
456impl QuoteMessage {
457 #[must_use]
459 pub fn into_replacement(self, old_order_id: OrderId) -> ReplaceQuoteMessage {
460 ReplaceQuoteMessage {
461 old_order_id,
462 rfq_id: self.rfq_id,
463 strike: self.strike,
464 price: self.price,
465 valid_until: self.valid_until,
466 nonce: self.nonce,
467 order_id: self.order_id,
468 signature: self.signature,
469 }
470 }
471}
472
473#[cfg(test)]
474#[path = "quote_tests.rs"]
475mod tests;