Skip to main content

acta_maker_sdk/ws/managed/
config.rs

1use std::time::Duration;
2
3use crate::ws::client::{PreparedClientMessage, WsTransportConfig};
4use crate::ws::error::WsTransportConfigError;
5use crate::ws::types::{ClientMessage, HelloData, SubscribeData};
6
7use super::endpoint::MakerWsEndpoint;
8use super::signer::ChallengeSigning;
9use crate::orders::SignerLike;
10use std::sync::Arc;
11
12/// Whether an inbound `Gap` also triggers reconnect and state recovery.
13///
14/// A gap on the quote endpoint reconnects by default, and cancel-on-disconnect
15/// then removes the session's active and retained non-winning quotes; selected
16/// and executing orders keep their lifecycle.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18#[non_exhaustive]
19pub enum GapPolicy {
20    /// Reconnect quote sessions; only surface gaps on data sessions.
21    #[default]
22    EndpointDefault,
23    Surface,
24    Reconnect,
25}
26
27const MAX_INBOUND_RING_SLOTS: usize = 65_536;
28const MAX_OUTBOUND_QUEUE_WIRE_BYTES: usize = 256 * 1024 * 1024;
29const SERVER_MAX_BATCH_QUOTES: usize = 50;
30const SERVER_MAX_CLIENT_MESSAGE_BYTES: usize = 32 * 1024;
31
32/// How the session proves its identity after `Welcome`.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub(crate) enum AuthFlow {
35    /// `StartAuth` / `ResumeAuth` as a maker.
36    Maker,
37    /// `StartVaultAuth` as a vault operator. The server issues no resumable
38    /// session, so every reconnect re-runs the challenge.
39    #[cfg_attr(not(feature = "vault"), allow(dead_code))]
40    Vault { vault_pda: crate::Address32 },
41}
42
43#[derive(Clone)]
44pub struct ManagedWsConfig {
45    pub url: String,
46    pub endpoint: MakerWsEndpoint,
47    pub(super) auth_pubkey: crate::Address32,
48    pub(super) challenge_signing: ChallengeSigning,
49    pub(crate) auth_flow: AuthFlow,
50    pub(super) initial_subscribe: Option<SubscribeData>,
51    pub reconnect_delay: Duration,
52    pub max_reconnect_delay: Duration,
53    /// Symmetric reconnect jitter ratio in the inclusive range 0.0..=1.0.
54    pub reconnect_jitter_ratio: f64,
55    /// Reconnects after the initial attempt. Zero means retry forever.
56    pub max_reconnect_attempts: u64,
57    /// Managed handshake deadline. Overrides `transport.connect_timeout` for this runtime.
58    pub connect_timeout: Duration,
59    pub ping_interval: Duration,
60    pub auth_timeout: Duration,
61    /// Deadline for initial subscription acknowledgement and recovery reads.
62    /// For live subscription changes, starts after the frame is written.
63    pub readiness_timeout: Duration,
64    pub write_timeout: Duration,
65    pub command_buffer: usize,
66    pub broadcast_buffer: usize,
67    /// Maximum number of quotes accepted in one outbound batch.
68    pub max_batch_quotes: usize,
69    /// Maximum serialized size of one outbound client message.
70    pub max_outbound_message_size: usize,
71    pub ws_read_timeout: Option<Duration>,
72    pub pong_timeout: Duration,
73    pub transport: WsTransportConfig,
74    pub max_pending_awaits: usize,
75    pub gap_policy: GapPolicy,
76    pub(super) hello: HelloData,
77}
78
79impl ManagedWsConfig {
80    /// Authenticate as maker owner `auth_pubkey`; `signer` must be its registered
81    /// quote-signing key (which may equal the owner key).
82    #[must_use]
83    pub fn new(
84        url: impl Into<String>,
85        hello: HelloData,
86        signer: Arc<dyn SignerLike + Send + Sync>,
87        auth_pubkey: impl Into<crate::Address32>,
88    ) -> Self {
89        Self::new_with_signing(
90            url,
91            hello,
92            auth_pubkey.into(),
93            ChallengeSigning::Local(signer),
94        )
95    }
96
97    pub(crate) fn new_with_signing(
98        url: impl Into<String>,
99        hello: HelloData,
100        auth_pubkey: crate::Address32,
101        challenge_signing: ChallengeSigning,
102    ) -> Self {
103        Self {
104            url: url.into(),
105            endpoint: MakerWsEndpoint::Quote,
106            auth_pubkey,
107            challenge_signing,
108            auth_flow: AuthFlow::Maker,
109            initial_subscribe: None,
110            reconnect_delay: Duration::from_millis(250),
111            max_reconnect_delay: Duration::from_secs(5),
112            reconnect_jitter_ratio: 0.2,
113            max_reconnect_attempts: 0,
114            connect_timeout: Duration::from_secs(10),
115            ping_interval: Duration::from_secs(30),
116            auth_timeout: Duration::from_secs(15),
117            readiness_timeout: Duration::from_secs(15),
118            write_timeout: Duration::from_secs(5),
119            command_buffer: 256,
120            broadcast_buffer: 4096,
121            max_batch_quotes: SERVER_MAX_BATCH_QUOTES,
122            max_outbound_message_size: SERVER_MAX_CLIENT_MESSAGE_BYTES,
123            ws_read_timeout: None,
124            pong_timeout: Duration::from_secs(10),
125            transport: WsTransportConfig::default(),
126            max_pending_awaits: 1024,
127            gap_policy: GapPolicy::EndpointDefault,
128            hello,
129        }
130    }
131
132    /// Short liveness deadlines, a larger command buffer and reconnect on inbound gaps.
133    #[must_use]
134    pub const fn low_latency(mut self) -> Self {
135        self.ping_interval = Duration::from_secs(2);
136        self.pong_timeout = Duration::from_secs(2);
137        self.write_timeout = Duration::from_secs(1);
138        self.command_buffer = 1024;
139        self.gap_policy = GapPolicy::Reconnect;
140        self
141    }
142
143    #[must_use]
144    pub const fn with_gap_policy(mut self, policy: GapPolicy) -> Self {
145        self.gap_policy = policy;
146        self
147    }
148
149    pub(super) fn reconnect_on_gap(&self) -> bool {
150        match self.gap_policy {
151            GapPolicy::EndpointDefault => self.endpoint == MakerWsEndpoint::Quote,
152            GapPolicy::Surface => false,
153            GapPolicy::Reconnect => true,
154        }
155    }
156
157    /// The caller-provided `Hello`; feature requests are never inferred.
158    pub(super) fn hello(&self) -> HelloData {
159        self.hello.clone()
160    }
161
162    #[must_use]
163    pub const fn with_endpoint(mut self, endpoint: MakerWsEndpoint) -> Self {
164        self.endpoint = endpoint;
165        self
166    }
167
168    /// Authenticate on `/vault` as an operator of `vault_pda`.
169    #[cfg(feature = "vault")]
170    pub(crate) fn with_vault_auth(
171        mut self,
172        vault_pda: crate::Address32,
173        products: crate::ProductSet,
174    ) -> Self {
175        self.auth_flow = AuthFlow::Vault { vault_pda };
176        self.initial_subscribe = Some(SubscribeData {
177            products,
178            request_id: uuid::Uuid::new_v4(),
179            channels: vec![crate::ws::types::WsChannel::Vaults],
180            underlying_mints: vec![],
181            quote_mints: vec![],
182        });
183        self.with_endpoint(MakerWsEndpoint::Vault)
184    }
185
186    pub(crate) fn vault_pda(&self) -> Option<&crate::Address32> {
187        match &self.auth_flow {
188            AuthFlow::Vault { vault_pda } => Some(vault_pda),
189            AuthFlow::Maker => None,
190        }
191    }
192
193    #[must_use]
194    pub fn with_initial_subscribe(mut self, subscribe: SubscribeData) -> Self {
195        self.initial_subscribe = Some(subscribe);
196        self
197    }
198
199    /// Validate task capacities and deadlines before spawning the session.
200    pub fn validate(&self) -> Result<(), ManagedWsConfigError> {
201        if self.url.trim().is_empty() {
202            return Err(ManagedWsConfigError::EmptyUrl);
203        }
204        for (field, capacity) in [
205            ("command_buffer", self.command_buffer),
206            ("broadcast_buffer", self.broadcast_buffer),
207            ("max_pending_awaits", self.max_pending_awaits),
208            ("max_batch_quotes", self.max_batch_quotes),
209            ("max_outbound_message_size", self.max_outbound_message_size),
210        ] {
211            if capacity == 0 {
212                return Err(ManagedWsConfigError::ZeroCapacity { field });
213            }
214        }
215        for (field, duration) in [
216            ("reconnect_delay", self.reconnect_delay),
217            ("max_reconnect_delay", self.max_reconnect_delay),
218            ("connect_timeout", self.connect_timeout),
219            ("ping_interval", self.ping_interval),
220            ("auth_timeout", self.auth_timeout),
221            ("readiness_timeout", self.readiness_timeout),
222            ("write_timeout", self.write_timeout),
223            ("pong_timeout", self.pong_timeout),
224        ] {
225            if duration.is_zero() {
226                return Err(ManagedWsConfigError::ZeroDuration { field });
227            }
228        }
229        if self
230            .ws_read_timeout
231            .is_some_and(|duration| duration.is_zero())
232        {
233            return Err(ManagedWsConfigError::ZeroDuration {
234                field: "ws_read_timeout",
235            });
236        }
237        if self.reconnect_delay > self.max_reconnect_delay {
238            return Err(ManagedWsConfigError::InvalidReconnectRange);
239        }
240        if !(0.0..=1.0).contains(&self.reconnect_jitter_ratio) {
241            return Err(ManagedWsConfigError::InvalidReconnectJitter);
242        }
243        validate_server_limit(
244            "max_batch_quotes",
245            self.max_batch_quotes,
246            SERVER_MAX_BATCH_QUOTES,
247        )?;
248        validate_server_limit(
249            "max_outbound_message_size",
250            self.max_outbound_message_size,
251            SERVER_MAX_CLIENT_MESSAGE_BYTES,
252        )?;
253        if self.endpoint == MakerWsEndpoint::Data && self.initial_subscribe.is_some() {
254            return Err(ManagedWsConfigError::InitialSubscribeOnDataEndpoint);
255        }
256        if (self.endpoint == MakerWsEndpoint::Vault) != self.vault_pda().is_some() {
257            return Err(ManagedWsConfigError::VaultEndpointAuthMismatch);
258        }
259        if let Some(subscribe) = &self.initial_subscribe {
260            let message = ClientMessage::Subscribe(subscribe.clone());
261            let prepared = PreparedClientMessage::new(&message)
262                .map_err(|_| ManagedWsConfigError::InitialSubscribeNotSerializable)?;
263            if prepared.len() > self.max_outbound_message_size {
264                return Err(ManagedWsConfigError::InitialSubscribeTooLarge {
265                    actual: prepared.len(),
266                    limit: self.max_outbound_message_size,
267                });
268            }
269        }
270        self.effective_transport().validate()?;
271        validate_slot_cap(
272            "broadcast_buffer",
273            self.broadcast_buffer,
274            MAX_INBOUND_RING_SLOTS,
275        )?;
276        validate_memory_envelope(
277            "outbound command queue",
278            self.command_buffer,
279            self.max_outbound_message_size,
280            MAX_OUTBOUND_QUEUE_WIRE_BYTES,
281        )
282    }
283
284    pub(super) const fn effective_transport(&self) -> WsTransportConfig {
285        let mut transport = self.transport;
286        transport.connect_timeout = self.connect_timeout;
287        transport
288    }
289}
290
291fn validate_server_limit(
292    field: &'static str,
293    configured: usize,
294    limit: usize,
295) -> Result<(), ManagedWsConfigError> {
296    if configured > limit {
297        return Err(ManagedWsConfigError::ProtocolLimitExceeded {
298            field,
299            configured,
300            limit,
301        });
302    }
303    Ok(())
304}
305
306/// Bound slot count; parsed messages have variable size, so this is not a byte limit.
307fn validate_slot_cap(
308    field: &'static str,
309    capacity: usize,
310    limit: usize,
311) -> Result<(), ManagedWsConfigError> {
312    if capacity > limit {
313        return Err(ManagedWsConfigError::CapacityTooLarge {
314            field,
315            configured: capacity,
316            limit,
317        });
318    }
319    Ok(())
320}
321
322/// Every outbound message is rejected above `max_outbound_message_size` before
323/// it is queued, so unlike the inbound ring this product is a true bound on the
324/// bytes the queue can hold.
325fn validate_memory_envelope(
326    queue: &'static str,
327    capacity: usize,
328    max_message_size: usize,
329    limit: usize,
330) -> Result<(), ManagedWsConfigError> {
331    let configured = capacity.saturating_mul(max_message_size);
332    if configured > limit {
333        return Err(ManagedWsConfigError::MemoryEnvelopeTooLarge {
334            queue,
335            configured,
336            limit,
337        });
338    }
339    Ok(())
340}
341
342#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)]
343#[non_exhaustive]
344pub enum ManagedWsConfigError {
345    #[error("managed websocket URL must not be empty")]
346    EmptyUrl,
347    #[error("managed websocket capacity `{field}` must be non-zero")]
348    ZeroCapacity { field: &'static str },
349    #[error("managed websocket duration `{field}` must be non-zero")]
350    ZeroDuration { field: &'static str },
351    #[error("maximum reconnect delay must not be shorter than the initial reconnect delay")]
352    InvalidReconnectRange,
353    #[error("reconnect jitter ratio must be in the inclusive range 0.0..=1.0")]
354    InvalidReconnectJitter,
355    #[error("initial subscriptions are not supported by the maker data endpoint")]
356    InitialSubscribeOnDataEndpoint,
357    #[error("the vault endpoint requires the vault auth flow, and vice versa")]
358    VaultEndpointAuthMismatch,
359    #[error("initial subscription is {actual} bytes, maximum is {limit}")]
360    InitialSubscribeTooLarge { actual: usize, limit: usize },
361    #[error("{field} is {configured}, server maximum is {limit}")]
362    ProtocolLimitExceeded {
363        field: &'static str,
364        configured: usize,
365        limit: usize,
366    },
367    #[error("initial subscribe cannot be serialized")]
368    InitialSubscribeNotSerializable,
369    #[error("{field} is {configured}, maximum is {limit}")]
370    CapacityTooLarge {
371        field: &'static str,
372        configured: usize,
373        limit: usize,
374    },
375    #[error("{queue} wire envelope is {configured} bytes, maximum is {limit}")]
376    MemoryEnvelopeTooLarge {
377        queue: &'static str,
378        configured: usize,
379        limit: usize,
380    },
381    #[error(transparent)]
382    Transport(#[from] WsTransportConfigError),
383}