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_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::{
15        TransactionUtil, VersionedMessageExt, VersionedTransactionOps, VersionedTransactionResolved,
16    },
17    validator::transaction_validator::TransactionValidator,
18    CacheUtil, KoraError,
19};
20
21#[derive(Debug, Deserialize, ToSchema)]
22pub struct TransferTransactionRequest {
23    pub amount: u64,
24    pub token: String,
25    pub source: String,
26    pub destination: String,
27    /// Optional signer signer_key to ensure consistency across related RPC calls
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub signer_key: Option<String>,
30}
31
32#[derive(Debug, Serialize, ToSchema)]
33pub struct TransferTransactionResponse {
34    pub transaction: String,
35    pub message: String,
36    pub blockhash: String,
37    /// Public key of the signer used (for client consistency)
38    pub signer_pubkey: String,
39}
40
41pub async fn transfer_transaction(
42    rpc_client: &Arc<RpcClient>,
43    request: TransferTransactionRequest,
44) -> Result<TransferTransactionResponse, KoraError> {
45    let signer = get_request_signer_with_signer_key(request.signer_key.as_deref())?;
46    let fee_payer = signer.pubkey();
47
48    let validator = TransactionValidator::new(fee_payer)?;
49
50    let source = Pubkey::from_str(&request.source)
51        .map_err(|e| KoraError::ValidationError(format!("Invalid source address: {e}")))?;
52    let destination = Pubkey::from_str(&request.destination)
53        .map_err(|e| KoraError::ValidationError(format!("Invalid destination address: {e}")))?;
54    let token_mint = Pubkey::from_str(&request.token)
55        .map_err(|e| KoraError::ValidationError(format!("Invalid token address: {e}")))?;
56
57    // manually check disallowed account because we're creating the message
58    if validator.is_disallowed_account(&source) {
59        return Err(KoraError::InvalidTransaction(format!(
60            "Source account {source} is disallowed"
61        )));
62    }
63
64    if validator.is_disallowed_account(&destination) {
65        return Err(KoraError::InvalidTransaction(format!(
66            "Destination account {destination} is disallowed"
67        )));
68    }
69
70    let mut instructions = vec![];
71
72    // Handle native SOL transfers
73    if request.token == NATIVE_SOL {
74        instructions.push(transfer(&source, &destination, request.amount));
75    } else {
76        // Handle wrapped SOL and other SPL tokens
77        let token_mint = validator.fetch_and_validate_token_mint(&token_mint, rpc_client).await?;
78        let token_program = token_mint.get_token_program();
79        let decimals = token_mint.decimals();
80
81        let source_ata = token_program.get_associated_token_address(&source, &token_mint.address());
82        let dest_ata =
83            token_program.get_associated_token_address(&destination, &token_mint.address());
84
85        CacheUtil::get_account(rpc_client, &source_ata, false)
86            .await
87            .map_err(|_| KoraError::AccountNotFound(source_ata.to_string()))?;
88
89        if CacheUtil::get_account(rpc_client, &dest_ata, false).await.is_err() {
90            instructions.push(token_program.create_associated_token_account_instruction(
91                &fee_payer,
92                &destination,
93                &token_mint.address(),
94            ));
95        }
96
97        instructions.push(
98            token_program
99                .create_transfer_checked_instruction(
100                    &source_ata,
101                    &token_mint.address(),
102                    &dest_ata,
103                    &source,
104                    request.amount,
105                    decimals,
106                )
107                .map_err(|e| {
108                    KoraError::InvalidTransaction(format!(
109                        "Failed to create transfer instruction: {e}"
110                    ))
111                })?,
112        );
113    }
114
115    let blockhash =
116        rpc_client.get_latest_blockhash_with_commitment(CommitmentConfig::confirmed()).await?;
117
118    let message = VersionedMessage::Legacy(Message::new_with_blockhash(
119        &instructions,
120        Some(&fee_payer),
121        &blockhash.0,
122    ));
123    let transaction = TransactionUtil::new_unsigned_versioned_transaction(message);
124
125    let mut resolved_transaction =
126        VersionedTransactionResolved::from_kora_built_transaction(&transaction)?;
127
128    // validate transaction before signing
129    validator.validate_transaction(&mut resolved_transaction, rpc_client).await?;
130
131    // Find the fee payer position in the account keys
132    let fee_payer_position = resolved_transaction.find_signer_position(&fee_payer)?;
133
134    let message_bytes = resolved_transaction.transaction.message.serialize();
135    let signature = signer
136        .sign_message(&message_bytes)
137        .await
138        .map_err(|e| KoraError::SigningError(e.to_string()))?;
139
140    resolved_transaction.transaction.signatures[fee_payer_position] = signature;
141
142    let encoded = resolved_transaction.encode_b64_transaction()?;
143    let message_encoded = transaction.message.encode_b64_message()?;
144
145    Ok(TransferTransactionResponse {
146        transaction: encoded,
147        message: message_encoded,
148        blockhash: blockhash.0.to_string(),
149        signer_pubkey: fee_payer.to_string(),
150    })
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::{
157        state::update_config,
158        tests::{
159            common::{setup_or_get_test_signer, RpcMockBuilder},
160            config_mock::ConfigMockBuilder,
161        },
162    };
163
164    #[tokio::test]
165    async fn test_transfer_transaction_invalid_source() {
166        let config = ConfigMockBuilder::new().build();
167        update_config(config).unwrap();
168        let _ = setup_or_get_test_signer();
169
170        let rpc_client = Arc::new(RpcMockBuilder::new().with_mint_account(6).build());
171
172        let request = TransferTransactionRequest {
173            amount: 1000,
174            token: Pubkey::new_unique().to_string(),
175            source: "invalid".to_string(),
176            destination: Pubkey::new_unique().to_string(),
177            signer_key: None,
178        };
179
180        let result = transfer_transaction(&rpc_client, request).await;
181
182        assert!(result.is_err(), "Should fail with invalid source address");
183        let error = result.unwrap_err();
184        assert!(matches!(error, KoraError::ValidationError(_)), "Should return ValidationError");
185        match error {
186            KoraError::ValidationError(error_message) => {
187                assert!(error_message.contains("Invalid source address"));
188            }
189            _ => panic!("Should return ValidationError"),
190        }
191    }
192
193    #[tokio::test]
194    async fn test_transfer_transaction_invalid_destination() {
195        let config = ConfigMockBuilder::new().build();
196        update_config(config).unwrap();
197        let _ = setup_or_get_test_signer();
198
199        let rpc_client = Arc::new(RpcMockBuilder::new().with_mint_account(6).build());
200
201        let request = TransferTransactionRequest {
202            amount: 1000,
203            token: Pubkey::new_unique().to_string(),
204            source: Pubkey::new_unique().to_string(),
205            destination: "invalid_pubkey".to_string(),
206            signer_key: None,
207        };
208
209        let result = transfer_transaction(&rpc_client, request).await;
210
211        assert!(result.is_err(), "Should fail with invalid destination address");
212        let error = result.unwrap_err();
213        match error {
214            KoraError::ValidationError(error_message) => {
215                assert!(error_message.contains("Invalid destination address"));
216            }
217            _ => panic!("Should return ValidationError"),
218        }
219    }
220
221    #[tokio::test]
222    async fn test_transfer_transaction_invalid_token() {
223        let config = ConfigMockBuilder::new().build();
224        update_config(config).unwrap();
225        let _ = setup_or_get_test_signer();
226
227        let rpc_client = Arc::new(RpcMockBuilder::new().with_mint_account(6).build());
228
229        let request = TransferTransactionRequest {
230            amount: 1000,
231            token: "invalid_token_address".to_string(),
232            source: Pubkey::new_unique().to_string(),
233            destination: Pubkey::new_unique().to_string(),
234            signer_key: None,
235        };
236
237        let result = transfer_transaction(&rpc_client, request).await;
238
239        assert!(result.is_err(), "Should fail with invalid token address");
240        let error = result.unwrap_err();
241        match error {
242            KoraError::ValidationError(error_message) => {
243                assert!(error_message.contains("Invalid token address"));
244            }
245            _ => panic!("Should return ValidationError"),
246        }
247    }
248}