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
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/// A chain account's deletion, independent of any entity/view mapping.
10/// Ingestion emits this only after recognizing an authoritative tombstone and
11/// enforcing its replay watermark; empty data alone does not imply deletion.
12#[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    /// None only when no underlying read took place (an empty batch).
116    #[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    /// Index/provider identity. This does not assert a chain commitment.
132    pub source: String,
133    /// Provider observation time as RFC3339, never fabricated by an SDK.
134    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    /// initialized, frozen, or uninitialized (provider must supply actual state).
212    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    /// Discovery returns addresses; verified typed reads are a separate contextual operation.
298    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;
316/// Preserve parsed instructions and future fields. Monetary/resource integers are decimal strings;
317/// all other integers outside JavaScript's exact range are strings too. Account indexes stay numeric.
318pub 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}