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