openzeppelin_relayer/models/rpc/solana/
mod.rs

1use base64::{engine::general_purpose::STANDARD, Engine};
2use serde::{Deserialize, Serialize};
3use solana_sdk::transaction::{Transaction, VersionedTransaction};
4use thiserror::Error;
5use utoipa::ToSchema;
6
7#[derive(Debug, Error, Deserialize, Serialize)]
8#[allow(clippy::enum_variant_names)]
9pub enum SolanaEncodingError {
10    #[error("Failed to serialize transaction: {0}")]
11    Serialization(String),
12    #[error("Failed to decode base64: {0}")]
13    Decode(String),
14    #[error("Failed to deserialize transaction: {0}")]
15    Deserialize(String),
16}
17
18#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
19pub struct EncodedSerializedTransaction(String);
20
21impl EncodedSerializedTransaction {
22    pub fn new(encoded: String) -> Self {
23        Self(encoded)
24    }
25
26    pub fn into_inner(self) -> String {
27        self.0
28    }
29}
30
31impl TryFrom<&solana_sdk::transaction::Transaction> for EncodedSerializedTransaction {
32    type Error = SolanaEncodingError;
33
34    fn try_from(transaction: &Transaction) -> Result<Self, Self::Error> {
35        let serialized = bincode::serialize(transaction)
36            .map_err(|e| SolanaEncodingError::Serialization(e.to_string()))?;
37
38        Ok(Self(STANDARD.encode(serialized)))
39    }
40}
41
42impl TryFrom<EncodedSerializedTransaction> for solana_sdk::transaction::Transaction {
43    type Error = SolanaEncodingError;
44
45    fn try_from(encoded: EncodedSerializedTransaction) -> Result<Self, Self::Error> {
46        let tx_bytes = STANDARD
47            .decode(encoded.0)
48            .map_err(|e| SolanaEncodingError::Decode(e.to_string()))?;
49
50        let decoded_tx: Transaction = bincode::deserialize(&tx_bytes)
51            .map_err(|e| SolanaEncodingError::Deserialize(e.to_string()))?;
52
53        Ok(decoded_tx)
54    }
55}
56
57// Implement conversion from versioned transaction
58impl TryFrom<&VersionedTransaction> for EncodedSerializedTransaction {
59    type Error = SolanaEncodingError;
60
61    fn try_from(transaction: &VersionedTransaction) -> Result<Self, Self::Error> {
62        let serialized = bincode::serialize(transaction)
63            .map_err(|e| SolanaEncodingError::Serialization(e.to_string()))?;
64
65        Ok(Self(STANDARD.encode(serialized)))
66    }
67}
68
69// Implement conversion to versioned transaction
70impl TryFrom<EncodedSerializedTransaction> for VersionedTransaction {
71    type Error = SolanaEncodingError;
72
73    fn try_from(encoded: EncodedSerializedTransaction) -> Result<Self, Self::Error> {
74        let tx_bytes = STANDARD
75            .decode(&encoded.0)
76            .map_err(|e| SolanaEncodingError::Decode(e.to_string()))?;
77
78        bincode::deserialize(&tx_bytes).map_err(|e| SolanaEncodingError::Deserialize(e.to_string()))
79    }
80}
81
82// feeEstimate
83#[derive(Debug, Deserialize, Serialize, PartialEq, ToSchema)]
84#[serde(deny_unknown_fields)]
85#[derive(Clone)]
86#[schema(as = SolanaFeeEstimateRequestParams)]
87pub struct FeeEstimateRequestParams {
88    pub transaction: EncodedSerializedTransaction,
89    pub fee_token: String,
90}
91
92#[derive(Debug, Deserialize, Serialize, PartialEq, Clone, ToSchema)]
93#[schema(as = SolanaFeeEstimateResult)]
94pub struct FeeEstimateResult {
95    pub estimated_fee: String,
96    pub conversion_rate: String,
97}
98
99// transferTransaction
100#[derive(Debug, Deserialize, Serialize, PartialEq, ToSchema)]
101#[serde(deny_unknown_fields)]
102#[derive(Clone)]
103pub struct TransferTransactionRequestParams {
104    pub amount: u64,
105    pub token: String,
106    pub source: String,
107    pub destination: String,
108}
109
110#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
111pub struct TransferTransactionResult {
112    pub transaction: EncodedSerializedTransaction,
113    pub fee_in_spl: String,
114    pub fee_in_lamports: String,
115    pub fee_token: String,
116    pub valid_until_blockheight: u64,
117}
118
119// prepareTransaction
120#[derive(Debug, Deserialize, Serialize, PartialEq, ToSchema)]
121#[serde(deny_unknown_fields)]
122#[derive(Clone)]
123#[schema(as = SolanaPrepareTransactionRequestParams)]
124pub struct PrepareTransactionRequestParams {
125    pub transaction: EncodedSerializedTransaction,
126    pub fee_token: String,
127}
128
129#[derive(Debug, Deserialize, Serialize, PartialEq, Clone, ToSchema)]
130#[schema(as = SolanaPrepareTransactionResult)]
131pub struct PrepareTransactionResult {
132    pub transaction: EncodedSerializedTransaction,
133    pub fee_in_spl: String,
134    pub fee_in_lamports: String,
135    pub fee_token: String,
136    pub valid_until_blockheight: u64,
137}
138
139// signTransaction
140#[derive(Debug, Deserialize, Serialize, PartialEq, ToSchema)]
141#[serde(deny_unknown_fields)]
142#[derive(Clone)]
143pub struct SignTransactionRequestParams {
144    pub transaction: EncodedSerializedTransaction,
145}
146
147#[derive(Debug, Deserialize, Serialize, PartialEq, Clone, ToSchema)]
148pub struct SignTransactionResult {
149    pub transaction: EncodedSerializedTransaction,
150    pub signature: String,
151}
152
153// signAndSendTransaction
154#[derive(Debug, Deserialize, Serialize, PartialEq, ToSchema)]
155#[serde(deny_unknown_fields)]
156#[derive(Clone)]
157pub struct SignAndSendTransactionRequestParams {
158    pub transaction: EncodedSerializedTransaction,
159}
160
161#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
162pub struct SignAndSendTransactionResult {
163    pub transaction: EncodedSerializedTransaction,
164    pub signature: String,
165    pub id: String,
166}
167
168// getSupportedTokens
169#[derive(Debug, Deserialize, Serialize, PartialEq, ToSchema)]
170#[serde(deny_unknown_fields)]
171#[derive(Clone)]
172pub struct GetSupportedTokensRequestParams {}
173
174#[derive(Debug, Deserialize, Serialize, PartialEq, ToSchema)]
175pub struct GetSupportedTokensItem {
176    pub mint: String,
177    pub symbol: String,
178    pub decimals: u8,
179    #[schema(nullable = false)]
180    pub max_allowed_fee: Option<u64>,
181    #[schema(nullable = false)]
182    pub conversion_slippage_percentage: Option<f32>,
183}
184
185#[derive(Debug, Deserialize, Serialize, PartialEq, ToSchema)]
186pub struct GetSupportedTokensResult {
187    pub tokens: Vec<GetSupportedTokensItem>,
188}
189
190// getFeaturesEnabled
191#[derive(Debug, Deserialize, Serialize, PartialEq, ToSchema)]
192#[serde(deny_unknown_fields)]
193#[derive(Clone)]
194pub struct GetFeaturesEnabledRequestParams {}
195
196#[derive(Debug, Deserialize, Serialize, PartialEq, ToSchema)]
197pub struct GetFeaturesEnabledResult {
198    pub features: Vec<String>,
199}
200
201pub enum SolanaRpcMethod {
202    FeeEstimate,
203    TransferTransaction,
204    PrepareTransaction,
205    SignTransaction,
206    SignAndSendTransaction,
207    GetSupportedTokens,
208    GetFeaturesEnabled,
209    Generic(String),
210}
211
212impl SolanaRpcMethod {
213    pub fn from_string(method: &str) -> Option<Self> {
214        match method {
215            "feeEstimate" => Some(SolanaRpcMethod::FeeEstimate),
216            "transferTransaction" => Some(SolanaRpcMethod::TransferTransaction),
217            "prepareTransaction" => Some(SolanaRpcMethod::PrepareTransaction),
218            "signTransaction" => Some(SolanaRpcMethod::SignTransaction),
219            "signAndSendTransaction" => Some(SolanaRpcMethod::SignAndSendTransaction),
220            "getSupportedTokens" => Some(SolanaRpcMethod::GetSupportedTokens),
221            "getFeaturesEnabled" => Some(SolanaRpcMethod::GetFeaturesEnabled),
222            _ => Some(SolanaRpcMethod::Generic(method.to_string())),
223        }
224    }
225}
226
227#[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq, Clone)]
228#[serde(tag = "method", content = "params")]
229#[schema(as = SolanaRpcRequest)]
230pub enum SolanaRpcRequest {
231    #[serde(rename = "feeEstimate")]
232    #[schema(example = "feeEstimate")]
233    FeeEstimate(FeeEstimateRequestParams),
234    #[serde(rename = "transferTransaction")]
235    #[schema(example = "transferTransaction")]
236    TransferTransaction(TransferTransactionRequestParams),
237    #[serde(rename = "prepareTransaction")]
238    #[schema(example = "prepareTransaction")]
239    PrepareTransaction(PrepareTransactionRequestParams),
240    #[serde(rename = "signTransaction")]
241    #[schema(example = "signTransaction")]
242    SignTransaction(SignTransactionRequestParams),
243    #[serde(rename = "signAndSendTransaction")]
244    #[schema(example = "signAndSendTransaction")]
245    SignAndSendTransaction(SignAndSendTransactionRequestParams),
246    #[serde(rename = "getSupportedTokens")]
247    #[schema(example = "getSupportedTokens")]
248    GetSupportedTokens(GetSupportedTokensRequestParams),
249    #[serde(rename = "getFeaturesEnabled")]
250    #[schema(example = "getFeaturesEnabled")]
251    GetFeaturesEnabled(GetFeaturesEnabledRequestParams),
252    #[serde(rename = "rawRpcRequest")]
253    #[schema(example = "rawRpcRequest")]
254    RawRpcRequest {
255        method: String,
256        params: serde_json::Value,
257    },
258}
259
260#[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq)]
261#[serde(tag = "method", rename_all = "camelCase")]
262pub enum SolanaRpcResult {
263    FeeEstimate(FeeEstimateResult),
264    TransferTransaction(TransferTransactionResult),
265    PrepareTransaction(PrepareTransactionResult),
266    SignTransaction(SignTransactionResult),
267    SignAndSendTransaction(SignAndSendTransactionResult),
268    GetSupportedTokens(GetSupportedTokensResult),
269    GetFeaturesEnabled(GetFeaturesEnabledResult),
270    RawRpc(serde_json::Value),
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use solana_sdk::{
277        hash::Hash,
278        message::Message,
279        pubkey::Pubkey,
280        signature::{Keypair, Signer},
281    };
282    use solana_system_interface::instruction;
283
284    fn create_test_transaction() -> Transaction {
285        let payer = Keypair::new();
286
287        let recipient = Pubkey::new_unique();
288        let instruction = instruction::transfer(
289            &payer.pubkey(),
290            &recipient,
291            1000, // lamports
292        );
293        let message = Message::new(&[instruction], Some(&payer.pubkey()));
294        Transaction::new(&[&payer], message, Hash::default())
295    }
296
297    #[test]
298    fn test_transaction_to_encoded() {
299        let transaction = create_test_transaction();
300
301        let result = EncodedSerializedTransaction::try_from(&transaction);
302        assert!(result.is_ok(), "Failed to encode transaction");
303
304        let encoded = result.unwrap();
305        assert!(
306            !encoded.into_inner().is_empty(),
307            "Encoded string should not be empty"
308        );
309    }
310
311    #[test]
312    fn test_encoded_to_transaction() {
313        let original_tx = create_test_transaction();
314        let encoded = EncodedSerializedTransaction::try_from(&original_tx).unwrap();
315
316        let result = solana_sdk::transaction::Transaction::try_from(encoded);
317
318        assert!(result.is_ok(), "Failed to decode transaction");
319        let decoded_tx = result.unwrap();
320        assert_eq!(
321            original_tx.message.account_keys, decoded_tx.message.account_keys,
322            "Account keys should match"
323        );
324        assert_eq!(
325            original_tx.message.instructions, decoded_tx.message.instructions,
326            "Instructions should match"
327        );
328    }
329
330    #[test]
331    fn test_invalid_base64_decode() {
332        let invalid_encoded = EncodedSerializedTransaction("invalid base64".to_string());
333        let result = Transaction::try_from(invalid_encoded);
334        assert!(matches!(
335            result.unwrap_err(),
336            SolanaEncodingError::Decode(_)
337        ));
338    }
339
340    #[test]
341    fn test_invalid_transaction_deserialize() {
342        // Create valid base64 but invalid transaction data
343        let invalid_data = STANDARD.encode("not a transaction");
344        let invalid_encoded = EncodedSerializedTransaction(invalid_data);
345
346        let result = Transaction::try_from(invalid_encoded);
347        assert!(matches!(
348            result.unwrap_err(),
349            SolanaEncodingError::Deserialize(_)
350        ));
351    }
352
353    #[test]
354    fn test_deserialize_fee_estimate_request() {
355        let params = serde_json::json!({
356            "transaction": EncodedSerializedTransaction::new("dGVzdA==".to_string()),
357            "fee_token": "TOKEN".to_string()
358        });
359
360        let json = serde_json::json!({
361            "method": "feeEstimate",
362            "params": params
363        });
364
365        let deserialized: SolanaRpcRequest =
366            serde_json::from_value(json).expect("Should deserialize");
367
368        match deserialized {
369            SolanaRpcRequest::FeeEstimate(p) => {
370                assert_eq!(p.fee_token, "TOKEN");
371            }
372            _ => panic!("Expected FeeEstimate variant"),
373        }
374    }
375
376    #[test]
377    fn test_deserialize_raw_rpc_request_wrapper() {
378        // rawRpcRequest wraps an inner object with method and params fields
379        let inner = serde_json::json!({
380            "method": "customMethod",
381            "params": { "foo": "bar" }
382        });
383
384        let json = serde_json::json!({
385            "method": "rawRpcRequest",
386            "params": inner
387        });
388
389        let deserialized: SolanaRpcRequest =
390            serde_json::from_value(json).expect("Should deserialize raw wrapper");
391
392        match deserialized {
393            SolanaRpcRequest::RawRpcRequest { method, params } => {
394                assert_eq!(method, "customMethod");
395                assert_eq!(params["foo"], "bar");
396            }
397            _ => panic!("Expected RawRpcRequest variant"),
398        }
399    }
400
401    #[test]
402    fn test_solana_rpc_method_from_string_generic() {
403        let known = SolanaRpcMethod::from_string("feeEstimate");
404        assert!(matches!(known, Some(SolanaRpcMethod::FeeEstimate)));
405
406        let other = SolanaRpcMethod::from_string("someUnknownMethod");
407        match other {
408            Some(SolanaRpcMethod::Generic(s)) => assert_eq!(s, "someUnknownMethod"),
409            _ => panic!("Expected Generic variant"),
410        }
411    }
412}