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