kora_lib/rpc_server/method/
transfer_transaction.rs

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