Skip to main content

kora_lib/rpc_server/method/
transfer_transaction.rs

1use serde::{Deserialize, Serialize};
2use solana_client::nonblocking::rpc_client::RpcClient;
3use solana_keychain::SolanaSigner;
4use solana_message::Message;
5use solana_sdk::{message::VersionedMessage, pubkey::Pubkey};
6use solana_system_interface::instruction::transfer;
7use std::{str::FromStr, sync::Arc};
8use utoipa::ToSchema;
9
10use crate::{
11    constant::NATIVE_SOL,
12    state::get_request_signer_with_signer_key,
13    transaction::{TransactionUtil, VersionedMessageExt},
14    validator::transaction_validator::TransactionValidator,
15    CacheUtil, KoraError,
16};
17
18#[cfg(not(test))]
19use crate::state::get_config;
20
21#[cfg(test)]
22use crate::tests::config_mock::mock_state::get_config;
23
24/// **DEPRECATED**: Use `getPaymentInstruction` instead for fee payment flows.
25/// This endpoint will be removed in a future version.
26///
27/// Request payload for creating a simple token or SOL transfer transaction.
28/// This endpoint constructs an unsigned transfer where Kora acts as the fee payer,
29/// but the user must still sign the transaction before it can be submitted.
30#[derive(Debug, Deserialize, ToSchema)]
31pub struct TransferTransactionRequest {
32    /// Amount to transfer (in the token's smallest unit, e.g. lamports for SOL)
33    pub amount: u64,
34    /// Token mint address to transfer (use native SOL address for SOL transfers)
35    pub token: String,
36    /// The source wallet address that will send the tokens
37    pub source: String,
38    /// The destination wallet address that will receive the tokens
39    pub destination: String,
40    /// Optional public key of the signer to ensure consistency
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub signer_key: Option<String>,
43}
44
45/// **DEPRECATED**: Use `getPaymentInstruction` instead for fee payment flows.
46///
47/// Response payload containing the constructed transfer transaction.
48#[derive(Debug, Serialize, ToSchema)]
49pub struct TransferTransactionResponse {
50    /// Unsigned base64-encoded transaction ready for the user to sign
51    pub transaction: String,
52    /// Unsigned base64-encoded message
53    pub message: String,
54    /// The blockhash used when constructing the transaction
55    pub blockhash: String,
56    /// Public key of the Kora signer used as the fee payer (for client consistency)
57    pub signer_pubkey: String,
58}
59
60/// **DEPRECATED**: Use `getPaymentInstruction` instead for fee payment flows.
61///
62/// Creates an unsigned transfer transaction from source to destination.
63/// Kora is the fee payer but does NOT sign - user must sign before submitting.
64#[deprecated(since = "2.2.0", note = "Use getPaymentInstruction instead for fee payment flows")]
65pub async fn transfer_transaction(
66    rpc_client: &Arc<RpcClient>,
67    request: TransferTransactionRequest,
68) -> Result<TransferTransactionResponse, KoraError> {
69    let signer = get_request_signer_with_signer_key(request.signer_key.as_deref())?;
70    let config = &get_config()?;
71    let signer_pubkey = signer.pubkey();
72
73    let validator = TransactionValidator::new(config, signer_pubkey)?;
74
75    let source = Pubkey::from_str(&request.source)
76        .map_err(|e| KoraError::ValidationError(format!("Invalid source address: {e}")))?;
77    let destination = Pubkey::from_str(&request.destination)
78        .map_err(|e| KoraError::ValidationError(format!("Invalid destination address: {e}")))?;
79    let token_mint = Pubkey::from_str(&request.token)
80        .map_err(|e| KoraError::ValidationError(format!("Invalid token address: {e}")))?;
81
82    // Check source and destination are not disallowed
83    if validator.is_disallowed_account(&source) {
84        return Err(KoraError::InvalidTransaction(format!(
85            "Source account {source} is disallowed"
86        )));
87    }
88    if validator.is_disallowed_account(&destination) {
89        return Err(KoraError::InvalidTransaction(format!(
90            "Destination account {destination} is disallowed"
91        )));
92    }
93
94    let mut instructions = vec![];
95
96    // Handle native SOL transfers
97    if request.token == NATIVE_SOL {
98        instructions.push(transfer(&source, &destination, request.amount));
99    } else {
100        // Handle wrapped SOL and other SPL tokens
101        let token_mint =
102            validator.fetch_and_validate_token_mint(&token_mint, config, rpc_client).await?;
103        let token_program = token_mint.get_token_program();
104        let decimals = token_mint.decimals();
105
106        let source_ata = token_program.get_associated_token_address(&source, &token_mint.address());
107        let dest_ata =
108            token_program.get_associated_token_address(&destination, &token_mint.address());
109
110        CacheUtil::get_account(config, rpc_client, &source_ata, false).await.map_err(
111            |e| match e {
112                KoraError::AccountNotFound(_) => KoraError::AccountNotFound(source_ata.to_string()),
113                other => other,
114            },
115        )?;
116
117        match CacheUtil::get_account(config, rpc_client, &dest_ata, false).await {
118            Ok(_) => {} // account exists, no ATA needed
119            Err(KoraError::AccountNotFound(_)) => {
120                // Create ATA for destination if it doesn't exist (Kora pays for ATA creation)
121                instructions.push(token_program.create_associated_token_account_instruction(
122                    &signer_pubkey, // Kora pays for ATA creation
123                    &destination,
124                    &token_mint.address(),
125                ));
126            }
127            Err(e) => return Err(e), // propagate real errors
128        }
129
130        instructions.push(
131            token_program
132                .create_transfer_checked_instruction(
133                    &source_ata,
134                    &token_mint.address(),
135                    &dest_ata,
136                    &source,
137                    request.amount,
138                    decimals,
139                )
140                .map_err(|e| {
141                    KoraError::InvalidTransaction(format!(
142                        "Failed to create transfer instruction: {e}"
143                    ))
144                })?,
145        );
146    }
147
148    let blockhash = CacheUtil::get_or_fetch_latest_blockhash(config, rpc_client).await?;
149
150    let message = VersionedMessage::Legacy(Message::new_with_blockhash(
151        &instructions,
152        Some(&signer_pubkey), // Kora as fee payer
153        &blockhash,
154    ));
155    let transaction = TransactionUtil::new_unsigned_versioned_transaction(message);
156
157    let encoded = TransactionUtil::encode_versioned_transaction(&transaction)?;
158    let message_encoded = transaction.message.encode_b64_message()?;
159
160    Ok(TransferTransactionResponse {
161        transaction: encoded,
162        message: message_encoded,
163        blockhash: blockhash.to_string(),
164        signer_pubkey: signer_pubkey.to_string(),
165    })
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::tests::{
172        common::{setup_or_get_test_signer, RpcMockBuilder},
173        config_mock::ConfigMockBuilder,
174    };
175
176    #[tokio::test]
177    #[allow(deprecated)]
178    async fn test_transfer_transaction_invalid_source() {
179        let _m = ConfigMockBuilder::new().build_and_setup();
180        let _ = setup_or_get_test_signer();
181
182        let rpc_client = Arc::new(RpcMockBuilder::new().with_mint_account(6).build());
183
184        let request = TransferTransactionRequest {
185            amount: 1000,
186            token: Pubkey::new_unique().to_string(),
187            source: "invalid".to_string(),
188            destination: Pubkey::new_unique().to_string(),
189            signer_key: None,
190        };
191
192        let result = transfer_transaction(&rpc_client, request).await;
193
194        assert!(result.is_err(), "Should fail with invalid source address");
195        let error = result.unwrap_err();
196        assert!(matches!(error, KoraError::ValidationError(_)), "Should return ValidationError");
197        match error {
198            KoraError::ValidationError(error_message) => {
199                assert!(error_message.contains("Invalid source address"));
200            }
201            _ => panic!("Should return ValidationError"),
202        }
203    }
204
205    #[tokio::test]
206    #[allow(deprecated)]
207    async fn test_transfer_transaction_invalid_destination() {
208        let _m = ConfigMockBuilder::new().build_and_setup();
209        let _ = setup_or_get_test_signer();
210
211        let rpc_client = Arc::new(RpcMockBuilder::new().with_mint_account(6).build());
212
213        let request = TransferTransactionRequest {
214            amount: 1000,
215            token: Pubkey::new_unique().to_string(),
216            source: Pubkey::new_unique().to_string(),
217            destination: "invalid".to_string(),
218            signer_key: None,
219        };
220
221        let result = transfer_transaction(&rpc_client, request).await;
222
223        assert!(result.is_err(), "Should fail with invalid destination address");
224        let error = result.unwrap_err();
225        match error {
226            KoraError::ValidationError(error_message) => {
227                assert!(error_message.contains("Invalid destination address"));
228            }
229            _ => panic!("Should return ValidationError"),
230        }
231    }
232
233    #[tokio::test]
234    #[allow(deprecated)]
235    async fn test_transfer_transaction_invalid_token() {
236        let _m = ConfigMockBuilder::new().build_and_setup();
237        let _ = setup_or_get_test_signer();
238
239        let rpc_client = Arc::new(RpcMockBuilder::new().with_mint_account(6).build());
240
241        let request = TransferTransactionRequest {
242            amount: 1000,
243            token: "invalid_token_address".to_string(),
244            source: Pubkey::new_unique().to_string(),
245            destination: Pubkey::new_unique().to_string(),
246            signer_key: None,
247        };
248
249        let result = transfer_transaction(&rpc_client, request).await;
250
251        assert!(result.is_err(), "Should fail with invalid token address");
252        let error = result.unwrap_err();
253        match error {
254            KoraError::ValidationError(error_message) => {
255                assert!(error_message.contains("Invalid token address"));
256            }
257            _ => panic!("Should return ValidationError"),
258        }
259    }
260
261    #[tokio::test]
262    #[allow(deprecated)]
263    async fn test_transfer_transaction_account_not_found_preserved() {
264        // Let's test the specific error mapping logic that was fixed at line 112.
265        let pubkey = Pubkey::new_unique();
266
267        // This is simulating the error returned by CacheUtil::get_account when the account is not found
268        let error_from_cache = KoraError::AccountNotFound(pubkey.to_string());
269
270        let mapped_error = Err::<(), KoraError>(error_from_cache).map_err(|e| match e {
271            KoraError::AccountNotFound(_) => KoraError::AccountNotFound(pubkey.to_string()),
272            other => other,
273        });
274
275        assert!(matches!(mapped_error.unwrap_err(), KoraError::AccountNotFound(_)));
276    }
277
278    #[tokio::test]
279    #[allow(deprecated)]
280    async fn test_transfer_transaction_rpc_error_preserved() {
281        // Test the specific error mapping logic that was fixed at line 112 for RPC errors.
282        let pubkey = Pubkey::new_unique();
283
284        // This is simulating the error returned by CacheUtil::get_account when there's an RPC error
285        let error_from_cache = KoraError::RpcError("Service Unavailable".to_string());
286
287        let mapped_error = Err::<(), KoraError>(error_from_cache).map_err(|e| match e {
288            KoraError::AccountNotFound(_) => KoraError::AccountNotFound(pubkey.to_string()),
289            other => other,
290        });
291
292        let err = mapped_error.unwrap_err();
293        assert!(matches!(err, KoraError::RpcError(_)));
294        if let KoraError::RpcError(msg) = err {
295            assert_eq!(msg, "Service Unavailable");
296        }
297    }
298}