Skip to main content

arete_solana_contracts/
lib.rs

1//! Managed Solana capabilities v1. Wire fixtures live in tests/fixtures/managed-solana-v1.
2use 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/// A chain account's deletion, independent of any entity/view mapping.
13/// Ingestion emits this only after recognizing an authoritative tombstone and
14/// enforcing its replay watermark; empty data alone does not imply deletion.
15#[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    /// None only when no underlying read took place (an empty batch).
119    #[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    /// Index/provider identity. This does not assert a chain commitment.
135    pub source: String,
136    /// Provider observation time as RFC3339, never fabricated by an SDK.
137    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    /// initialized, frozen, or uninitialized (provider must supply actual state).
215    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    /// Discovery returns addresses; verified typed reads are a separate contextual operation.
301    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;
319/// Preserve parsed instructions and future fields. Monetary/resource integers are decimal strings;
320/// all other integers outside JavaScript's exact range are strings too. Account indexes stay numeric.
321pub 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}