kora_lib/rpc_server/method/
transfer_transaction.rs1use 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 #[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 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 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 if request.token == NATIVE_SOL {
74 instructions.push(transfer(&source, &destination, request.amount));
75 } else {
76 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 validator.validate_transaction(&mut resolved_transaction, rpc_client).await?;
130
131 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}