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