diff --git a/Cargo.lock b/Cargo.lock index 05ba2b8..23b070c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1476,6 +1476,7 @@ dependencies = [ name = "icepick-solana" version = "0.1.0" dependencies = [ + "base64 0.22.1", "ed25519-dalek 1.0.1", "icepick-module", "serde", @@ -1484,6 +1485,7 @@ dependencies = [ "solana-sdk", "spl-associated-token-account", "spl-token", + "spl-token-2022", "thiserror 2.0.3", ] diff --git a/crates/by-chain/icepick-solana/Cargo.toml b/crates/by-chain/icepick-solana/Cargo.toml index 514e1e0..c9d2de3 100644 --- a/crates/by-chain/icepick-solana/Cargo.toml +++ b/crates/by-chain/icepick-solana/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +base64 = "0.22.1" ed25519-dalek = "=1.0.1" icepick-module = { version = "0.1.0", path = "../../icepick-module" } serde = { workspace = true, features = ["derive"] } @@ -12,4 +13,5 @@ solana-rpc-client = { version = "2.1.1", default-features = false } solana-sdk = { version = "2.1.1" } spl-associated-token-account = "6.0.0" spl-token = "7.0.0" +spl-token-2022 = "6.0.0" thiserror = "2.0.3" diff --git a/crates/by-chain/icepick-solana/src/lib.rs b/crates/by-chain/icepick-solana/src/lib.rs index 2ff4035..80afb9f 100644 --- a/crates/by-chain/icepick-solana/src/lib.rs +++ b/crates/by-chain/icepick-solana/src/lib.rs @@ -147,6 +147,20 @@ pub struct CreateTokenAccount { blockhash: String, } +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct TransferToken { + amount: String, + token_address: String, + blockhash: String, + to_address: String, + from_account: Option, + from_address: String, + fee: Option, + fee_payer: Option, + fee_payer_address: Option, +} + #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "kebab-case")] pub struct Sign {} @@ -180,6 +194,7 @@ pub enum Operation { GetTokenAddress(GetTokenAddress), Transfer(Transfer), CreateTokenAccount(CreateTokenAccount), + TransferToken(TransferToken), Sign(Sign), Broadcast(Broadcast), } @@ -336,6 +351,38 @@ impl Module for Solana { }, ], }, + icepick_module::help::Operation { + name: "transfer-token".to_string(), + description: "Transfer tokens from a Keyfork wallet to an external wallet." + .to_string(), + arguments: vec![ + Argument { + name: "amount".to_string(), + description: "The amount of tokens to transfer.".to_string(), + r#type: ArgumentType::Required, + }, + Argument { + name: "token-address".to_string(), + description: "The address of the token.".to_string(), + r#type: ArgumentType::Required, + }, + account.clone(), + Argument { + name: "to-address".to_string(), + description: "The address to send the tokens to.".to_string(), + r#type: ArgumentType::Required, + }, + Argument { + name: "from-address".to_string(), + description: "The address to send the tokens from; will be used to verify the derivation account.".to_string(), + r#type: ArgumentType::Required, + }, + blockhash.clone(), + fee.clone(), + fee_payer.clone(), + fee_payer_address.clone(), + ], + }, icepick_module::help::Operation { name: "sign".to_string(), description: "Sign a previously-generated transaction.".to_string(), @@ -484,6 +531,101 @@ impl Module for Solana { "derivation-accounts": [0u32 | 1 << 31], })) } + Operation::TransferToken(TransferToken { + amount, + token_address, + blockhash, + to_address, + from_account, + from_address, + fee, + fee_payer, + fee_payer_address, + }) => { + // TODO: deduplicate code used in Transfer + + // no transfer between types of currency, the only amount is the amount + // of the lowest denomination. no floats, like with SOL / lamports. + let amount = u64::from_str(&amount).expect("integer amount"); + + use solana_sdk::pubkey::Pubkey; + use spl_associated_token_account::get_associated_token_address; + let to_pk = Pubkey::from_str(&to_address).unwrap(); + let from_pk = Pubkey::from_str(&from_address).unwrap(); + let token_pk = Pubkey::from_str(&token_address).unwrap(); + let payer_account_and_pk = { + // If a fee payer is given, a fee payer address must also be given, since the + // address must be known before signing the transaction. + match (&fee_payer, &fee_payer_address) { + (Some(payer), Some(address)) => { + // Use the provided account + Some(( + u32::from_str(payer).unwrap(), + Pubkey::from_str(address).unwrap(), + )) + } + (None, None) => { + // Use the transaction account + None + } + _ => panic!("Invalid combination of fee_payer and fee_payer_address"), + } + }; + let token_program_id = spl_token::ID; + let mut signers = vec![&from_pk]; + if let Some((_, pk)) = payer_account_and_pk.as_ref() { + signers.push(pk); + } + + let from_token_address = get_associated_token_address(&from_pk, &token_pk); + let to_token_address = get_associated_token_address(&to_pk, &token_pk); + let decimals = 9u8; + let mut instruction = spl_token_2022::instruction::transfer_checked( + &token_program_id, // token program id + &from_token_address, // source, as token address + &token_pk, // mint + &to_token_address, // destination, as token address + &from_pk, // authority, as source sol address + // TODO: signers should be [] when not using multisig + // but should contain all signers when multisig + &[], // signers + // TODO: make amount floatable + amount * 10u64.pow(decimals as u32), // amount + decimals, // decimals + ) + .unwrap(); + // TODO: check if this works with payer + // this is required because the Solana SDK does not set the primary transactional + // key as writable (the one that would be paying computation fees) in the event a + // payer is not provided. The transactional account must be writable for the + // computation fee to be paid. + if payer_account_and_pk.is_none() { + for account in instruction.accounts.iter_mut() { + if account.pubkey == from_pk { + account.is_writable = true; + } + } + } + let hash = solana_sdk::hash::Hash::from_str(&blockhash).unwrap(); + let message = solana_sdk::message::Message::new_with_blockhash( + &[instruction], + payer_account_and_pk.map(|v| v.1).as_ref(), + &hash, + ); + // message.header.num_readonly_signed_accounts = 0; + let transaction = + solana_sdk::transaction::Transaction::new_unsigned(message.clone()); + /* + use base64::prelude::*; + eprintln!("{}", BASE64_STANDARD.encode(transaction.message_data())); + */ + + #[allow(clippy::identity_op)] + Ok(serde_json::json!({ + "blob": transaction, + "derivation-accounts": [0u32 | 1 << 31], + })) + } Operation::Sign(_) => { let blob = request.blob.expect("passed in instruction blob"); let mut transaction: solana_sdk::transaction::Transaction =