Skip to content

feat(protocol): [2] solana support#2622

Merged
onur-ozkan merged 28 commits intodevfrom
solana-withdraw
Oct 27, 2025
Merged

feat(protocol): [2] solana support#2622
onur-ozkan merged 28 commits intodevfrom
solana-withdraw

Conversation

@onur-ozkan
Copy link

@onur-ozkan onur-ozkan commented Sep 16, 2025

Implements withdraw and send_raw_transaction RPCs (covered with an integration test) for Solana coin and tokens along with some other minor parts.

Previous implementations:

Signed-off-by: Onur Özkan <work@onurozkan.dev>
Signed-off-by: Onur Özkan <work@onurozkan.dev>
Signed-off-by: Onur Özkan <work@onurozkan.dev>
Signed-off-by: Onur Özkan <work@onurozkan.dev>
Signed-off-by: Onur Özkan <work@onurozkan.dev>
Signed-off-by: Onur Özkan <work@onurozkan.dev>
Signed-off-by: Onur Özkan <work@onurozkan.dev>
Signed-off-by: Onur Özkan <work@onurozkan.dev>
Signed-off-by: Onur Özkan <work@onurozkan.dev>
Signed-off-by: Onur Özkan <work@onurozkan.dev>

#[cfg(test)]
pub mod tendermint_falsecoin_tests {
pub mod tests {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like was renamed unintentionally in #2223.

Signed-off-by: Onur Özkan <work@onurozkan.dev>
Signed-off-by: Onur Özkan <work@onurozkan.dev>
Signed-off-by: Onur Özkan <work@onurozkan.dev>
Signed-off-by: Onur Özkan <work@onurozkan.dev>
Signed-off-by: Onur Özkan <work@onurozkan.dev>
Signed-off-by: Onur Özkan <work@onurozkan.dev>
@onur-ozkan onur-ozkan marked this pull request as ready for review October 7, 2025 09:05
Copy link
Collaborator

@shamardy shamardy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR! First review iteration from my side!

Comment on lines 253 to 258
instructions.push(create_associated_token_account(
&coin.address,
&to,
&token.protocol_info.mint_address,
&spl_token_program::id(),
));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't we make sure we can cover the rent in such case? We should also account for the rent as part of the fee or add it to SolanaFeeDetails which makes more sense.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know what the rent is. I need to learn it first.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would've preferred it in this PR as it's related to withdraw, but it can be in a small PR next before transaction history.

Signed-off-by: Onur Özkan <work@onurozkan.dev>
Signed-off-by: Onur Özkan <work@onurozkan.dev>
Signed-off-by: Onur Özkan <work@onurozkan.dev>
Signed-off-by: Onur Özkan <work@onurozkan.dev>
Copy link
Collaborator

@mariocynicys mariocynicys left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

awesome work!

first iter from my side, mostly questions xD

Comment on lines 330 to 335
if lamports == 0 {
return MmError::err(WithdrawError::AmountTooLow {
amount: req.amount,
threshold: coin.min_tx_amount(),
});
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it won't == 0, we do that check above in calculate-withdraw_amount (checking with coin.min_tx_amount())

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes but it's hard to track (if we change calculate_withdraw_amount we will most likely forget to add the check in withdraw). So for the sake of safety/explicitly I would still keep it here to ensure everything is guaranteed.

Comment on lines 357 to 359
let fee = rpc
.get_fee_for_message(tx.message())
.map_err(|e| WithdrawError::Transport(e.to_string()))?;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cant we calculate the tx fee internally without invoking the rpc?
also im not sure if this method does an estimation or query the fee for this specific transaction from the blockchain?

nvm, i thought that solana_system_transaction::transfer sends the transaction on-chain.
but then i got a Q: does this mean that the tx fee is calculated dynamically based on the blockchain conditions? do we have any control/limit over the fees that could be spent from out account at the time of tx publishing?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: does this mean that the tx fee is calculated dynamically based on the blockchain conditions?

Yes.

Do we have any control/limit over the fees that could be spent from out account at the time of tx publishing?

Well, we see the fee before we broadcasting it, so there is a bit of control.

Comment on lines 174 to 176
fn token_id(&self) -> RpcBytes {
sha256(self.ticker().to_lowercase().as_bytes()).to_vec().into()
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't we want to relate token_id to the mint address rather than the ticker, as the ticker might change, no?
or two tokens might have the same ticker?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure if it's ever possible to be 2 different tokens with the same ticker on the same chain. But I do liked using the mint address more.

Comment on lines 253 to 259
if let Err(e) = rpc.get_account(&to_token_account) {
if !e.kind.to_string().contains("AccountNotFound") {
return MmError::err(WithdrawError::Transport(e.to_string()));
}

instructions.push(create_associated_token_account(
&coin.address,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: what happens if when publishing the tx we found out that the account has already been created? will the tx fail or this instruction get ignored?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess you are referring to the account being created between the transaction generation and the sending phase? In that case the transaction would fail. There is another function that allows it to succeed even if the account already exists, I can use that too if we don't want TX to fail.


if let Err(e) = rpc.get_account(&to_token_account) {
if !e.kind.to_string().contains("AccountNotFound") {
return MmError::err(WithdrawError::Transport(e.to_string()));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to remind that it's preferable to return error codes rather string messages,
especially when it is important to show errors translated into the user native language.
Here user probably would like to get a message in their native language that the destination account is not found?

I see in the KW repo they have a framework for error translation by error codes. So I think we should return more error codes.
(I guess we should return string descriptions (non-translatable) only when we get unrecoverable internal errors).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are not sending custom string message here. The whole error converted into string so we can use it in WithdrawError. User will see the complete error message including the error code (if there are any) wrapped with KDF WithdrawError.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@onur-ozkan we can match the .kind field instead of serializing it and perform the contains() check.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not quite possible.

Signed-off-by: Onur Özkan <work@onurozkan.dev>
@onur-ozkan onur-ozkan force-pushed the solana-withdraw branch 3 times, most recently from 07c6762 to 4c39cb3 Compare October 16, 2025 08:06
Signed-off-by: Onur Özkan <work@onurozkan.dev>
shamardy
shamardy previously approved these changes Oct 23, 2025
Copy link
Collaborator

@shamardy shamardy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left some nits and things to do in a subsequent PR or here, whatever you prefer. Other than that, LGTM!

Comment on lines 193 to 201
pub(crate) async fn rpc_client(&self) -> MmResult<Arc<RpcClient>, String> {
let mut rpcs = self.rpc_clients.lock().await;

if let Some(index) = rpcs.iter().position(|rpc| rpc.get_health().is_ok()) {
// Put healthy one to the front.
rpcs.rotate_left(index);

return Ok(rpcs[0].clone());
for (index, rpc) in rpcs.iter().enumerate() {
if rpc.get_health().await.is_ok() {
rpcs.rotate_left(index);
return Ok(rpcs[0].clone());
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment for next PRs, please think about randomizing RPCs order on coin activation.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean? Why should we randomize RPCs order?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To not let all users use the first one and overload it, load should be distributed. At least it was done like that for EVMs. c.c. @cipig I would like his opinion on this.
But for GUI authenticated RPCs, we can have randomization as optional or we have a preferred primary RPC selected if needed.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To not let all users use the first one and overload it, load should be distributed. At least it was done like that for EVMs.

This would kill the ability of controlling the prioritization of the nodes we add to the application (e.g., having the paid node as the last backup and the most stable one as the primary/main node).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand this, we should also have a way to prioritize them into primary and secondary, there was an issue for this for other coins #1520
But all this is very low priority, so keep it as is for now.

Comment on lines 253 to 258
instructions.push(create_associated_token_account(
&coin.address,
&to,
&token.protocol_info.mint_address,
&spl_token_program::id(),
));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would've preferred it in this PR as it's related to withdraw, but it can be in a small PR next before transaction history.

&coin.address,
&to,
&token.protocol_info.mint_address,
&spl_token_program::id(),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spl_token_program::id() shouldn't be used IMHO, there is Token-2022 standard with different program id. Can be fixed in the rent followup PR as well. Not only here but in other places e.g. use get_associated_token_address_with_program_id instead of get_associated_token_address etc..

Copy link
Author

@onur-ozkan onur-ozkan Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_associated_token_address_with_program_id doesn't return program id, it takes it. I guess the best we can do is to put that value into the coins file in this case.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_associated_token_address_with_program_id doesn't return program id, it takes it

I am aware, I mean to pass the right program id by using get_associated_token_address_with_program_id instead of get_associated_token_address

Copy link
Collaborator

@shamardy shamardy Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the best we can do is to put that value into the coins file in this case.

There should be a token type / standard in coins file, as for imported tokens in the future, there are ways to detect which token standard it uses.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can safely use them statically in the coins file without needing a dynamic detection logic, what do you think? Adding an extra field to coins file for tokens shouldn't do any harm. I made it here.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can safely use them statically in the coins file without needing a dynamic detection logic, what do you think?

For Solana it's different and most wallets do dynamic detection if I am not mistaken, Phantom even detects any coin you have in your wallet that is not a scam / spam (their spam detection fails sometimes and have a way for users to mark tokens as spam). Please check industry standards for this and choose what is a right fit for Solana.

Signed-off-by: Onur Özkan <work@onurozkan.dev>
Signed-off-by: Onur Özkan <work@onurozkan.dev>
Signed-off-by: Onur Özkan <work@onurozkan.dev>
Signed-off-by: Onur Özkan <work@onurozkan.dev>
}

rent_lamports = rpc
.get_minimum_balance_for_rent_exemption(spl_token::state::Account::LEN)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the length may differ for Token-2022 as they can add optional extensions however they like, so the rent exemption calculation will be a bit more complicated by checking each token for which extensions enabled, etc..

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a TODO.

Signed-off-by: Onur Özkan <work@onurozkan.dev>
@onur-ozkan onur-ozkan merged commit 9353a5d into dev Oct 27, 2025
19 of 25 checks passed
@onur-ozkan onur-ozkan deleted the solana-withdraw branch October 27, 2025 05:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants