1use serde::{Deserialize, Serialize};
3
4#[cfg(feature = "raw-capture")]
5pub mod raw_capture;
6
7pub const CONTRACT_VERSION: &str = "managed-solana/v1";
8pub const MAX_BATCH_ADDRESSES: usize = 100;
9pub const MAX_PAGE_SIZE: u16 = 100;
10pub const MAX_CURSOR_BYTES: usize = 2048;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase", deny_unknown_fields)]
17pub struct AccountTombstone {
18 pub address: String,
19 #[serde(with = "decimal_u64")]
20 pub slot: u64,
21 #[serde(with = "decimal_u64")]
22 pub write_version: u64,
23}
24
25impl AccountTombstone {
26 pub fn validate(&self) -> Result<(), String> {
27 validate_address(&self.address)
28 }
29}
30
31pub mod decimal_u64 {
32 use serde::{Deserialize, Deserializer, Serializer};
33 pub fn serialize<S: Serializer>(value: &u64, serializer: S) -> Result<S::Ok, S::Error> {
34 serializer.serialize_str(&value.to_string())
35 }
36 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u64, D::Error> {
37 let value = String::deserialize(deserializer)?;
38 if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
39 return Err(serde::de::Error::custom("expected decimal u64 string"));
40 }
41 value.parse().map_err(serde::de::Error::custom)
42 }
43}
44
45pub mod optional_decimal_u64 {
46 use serde::{Deserialize, Deserializer, Serializer};
47 pub fn serialize<S: Serializer>(value: &Option<u64>, serializer: S) -> Result<S::Ok, S::Error> {
48 match value {
49 Some(value) => serializer.serialize_some(&value.to_string()),
50 None => serializer.serialize_none(),
51 }
52 }
53 pub fn deserialize<'de, D: Deserializer<'de>>(
54 deserializer: D,
55 ) -> Result<Option<u64>, D::Error> {
56 let value = Option::<String>::deserialize(deserializer)?;
57 value
58 .map(|value| {
59 if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) {
60 return Err(serde::de::Error::custom("expected decimal u64 string"));
61 }
62 value.parse().map_err(serde::de::Error::custom)
63 })
64 .transpose()
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "lowercase")]
70pub enum Commitment {
71 Processed,
72 Confirmed,
73 Finalized,
74}
75
76impl Commitment {
77 pub fn as_str(self) -> &'static str {
78 match self {
79 Self::Processed => "processed",
80 Self::Confirmed => "confirmed",
81 Self::Finalized => "finalized",
82 }
83 }
84}
85
86#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "camelCase", deny_unknown_fields)]
88pub struct ReadOptions {
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub commitment: Option<Commitment>,
91 #[serde(
92 default,
93 with = "optional_decimal_u64",
94 skip_serializing_if = "Option::is_none"
95 )]
96 pub min_context_slot: Option<u64>,
97}
98
99impl ReadOptions {
100 pub fn rpc_config(self) -> serde_json::Value {
101 let mut value = serde_json::json!({ "encoding": "base64", "commitment": self.commitment.unwrap_or(Commitment::Confirmed).as_str() });
102 if let Some(slot) = self.min_context_slot {
103 value["minContextSlot"] = slot.into();
104 }
105 value
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct ReadContext {
111 #[serde(with = "decimal_u64")]
112 pub slot: u64,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(bound(deserialize = "T: Deserialize<'de>"))]
117pub struct Contextual<T> {
118 #[serde(deserialize_with = "required_value")]
120 pub context: Option<ReadContext>,
121 #[serde(deserialize_with = "required_value")]
122 pub value: T,
123}
124
125fn required_value<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(
126 deserializer: D,
127) -> Result<T, D::Error> {
128 T::deserialize(deserializer)
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase")]
133pub struct DiscoveryProvenance {
134 pub source: String,
136 pub observed_at: String,
138 #[serde(
139 default,
140 with = "optional_decimal_u64",
141 skip_serializing_if = "Option::is_none"
142 )]
143 pub watermark: Option<u64>,
144}
145
146impl DiscoveryProvenance {
147 pub fn validate(&self) -> Result<(), String> {
148 if self.source.is_empty()
149 || chrono::DateTime::parse_from_rfc3339(&self.observed_at).is_err()
150 {
151 return Err("provider omitted valid RFC3339 provenance".into());
152 }
153 Ok(())
154 }
155}
156
157fn default_limit() -> u16 {
158 MAX_PAGE_SIZE
159}
160pub fn validate_address(address: &str) -> Result<(), String> {
161 if address.len() <= 44
162 && bs58::decode(address)
163 .into_vec()
164 .is_ok_and(|bytes| bytes.len() == 32)
165 {
166 Ok(())
167 } else {
168 Err("address must be a base58-encoded 32-byte value".into())
169 }
170}
171pub fn validate_page(limit: u16, cursor: Option<&str>) -> Result<(), String> {
172 if limit == 0 || limit > MAX_PAGE_SIZE {
173 return Err(format!("limit must be between 1 and {MAX_PAGE_SIZE}"));
174 }
175 if cursor.is_some_and(|c| c.is_empty() || c.len() > MAX_CURSOR_BYTES) {
176 return Err("cursor must contain 1..2048 bytes".into());
177 }
178 Ok(())
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase", deny_unknown_fields)]
183pub struct OwnerTokenAccountsRequest {
184 pub owner: String,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub mint: Option<String>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub token_program: Option<String>,
189 #[serde(default = "default_limit")]
190 pub limit: u16,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub cursor: Option<String>,
193}
194impl OwnerTokenAccountsRequest {
195 pub fn validate(&self) -> Result<(), String> {
196 validate_address(&self.owner)?;
197 for address in [&self.mint, &self.token_program].into_iter().flatten() {
198 validate_address(address)?;
199 }
200 validate_page(self.limit, self.cursor.as_deref())
201 }
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "camelCase")]
206pub struct OwnerTokenAccount {
207 pub address: String,
208 pub mint: String,
209 pub token_program: String,
210 pub owner: String,
211 #[serde(with = "decimal_u64")]
212 pub amount: u64,
213 pub decimals: u8,
214 pub state: String,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub delegate: Option<String>,
218 #[serde(
219 default,
220 with = "optional_decimal_u64",
221 skip_serializing_if = "Option::is_none"
222 )]
223 pub delegated_amount: Option<u64>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub close_authority: Option<String>,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(rename_all = "camelCase")]
230pub struct OwnerTokenAccountsPage {
231 pub items: Vec<OwnerTokenAccount>,
232 #[serde(deserialize_with = "required_value")]
233 pub next_cursor: Option<String>,
234 pub discovery: DiscoveryProvenance,
235}
236impl OwnerTokenAccountsPage {
237 pub fn validate(&self, request: &OwnerTokenAccountsRequest) -> Result<(), String> {
238 validate_page(request.limit, self.next_cursor.as_deref())?;
239 if self.items.len() > request.limit as usize {
240 return Err("provider exceeded page limit".into());
241 }
242 self.discovery.validate()?;
243 for item in &self.items {
244 for address in [&item.address, &item.owner, &item.mint, &item.token_program] {
245 validate_address(address)?;
246 }
247 for address in [&item.delegate, &item.close_authority]
248 .into_iter()
249 .flatten()
250 {
251 validate_address(address)?;
252 }
253 if item.owner != request.owner
254 || request.mint.as_ref().is_some_and(|m| *m != item.mint)
255 || request
256 .token_program
257 .as_ref()
258 .is_some_and(|p| *p != item.token_program)
259 {
260 return Err("provider returned an account outside the requested filters".into());
261 }
262 if !matches!(
263 item.state.as_str(),
264 "initialized" | "frozen" | "uninitialized"
265 ) {
266 return Err("provider returned an unsupported token state".into());
267 }
268 }
269 Ok(())
270 }
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
274#[serde(rename_all = "camelCase", deny_unknown_fields)]
275pub struct NativePositionQuery {
276 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub owner: Option<String>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub pool: Option<String>,
280 #[serde(default = "default_limit")]
281 pub limit: u16,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub cursor: Option<String>,
284}
285impl NativePositionQuery {
286 pub fn validate(&self) -> Result<(), String> {
287 if self.owner.is_none() && self.pool.is_none() {
288 return Err("owner or pool is required".into());
289 }
290 for address in [&self.owner, &self.pool].into_iter().flatten() {
291 validate_address(address)?;
292 }
293 validate_page(self.limit, self.cursor.as_deref())
294 }
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298#[serde(rename_all = "camelCase")]
299pub struct NativePositionPage {
300 pub addresses: Vec<String>,
302 #[serde(deserialize_with = "required_value")]
303 pub next_cursor: Option<String>,
304 pub discovery: DiscoveryProvenance,
305}
306
307impl Default for NativePositionQuery {
308 fn default() -> Self {
309 Self {
310 owner: None,
311 pool: None,
312 limit: MAX_PAGE_SIZE,
313 cursor: None,
314 }
315 }
316}
317
318use serde_json::Value;
319pub fn precision_safe_json(value: &Value, key: &str) -> Value {
322 match value {
323 Value::Object(object) => Value::Object(
324 object
325 .iter()
326 .map(|(key, value)| (key.clone(), precision_safe_json(value, key)))
327 .collect(),
328 ),
329 Value::Array(array) => Value::Array(
330 array
331 .iter()
332 .map(|value| precision_safe_json(value, key))
333 .collect(),
334 ),
335 Value::Number(number) if number.is_u64() || number.is_i64() => {
336 let exact_field = matches!(
337 key,
338 "fee"
339 | "preBalances"
340 | "postBalances"
341 | "lamports"
342 | "postBalance"
343 | "computeUnitsConsumed"
344 | "costUnits"
345 | "loadedAccountsDataSize"
346 );
347 let unsafe_integer = number.as_u64().is_some_and(|n| n > 9_007_199_254_740_991)
348 || number.as_i64().is_some_and(|n| n < -9_007_199_254_740_991);
349 if exact_field || unsafe_integer {
350 Value::String(number.to_string())
351 } else {
352 value.clone()
353 }
354 }
355 _ => value.clone(),
356 }
357}