diff --git a/solana-programs/claimable-tokens/cli/src/main.rs b/solana-programs/claimable-tokens/cli/src/main.rs index 64fc07b8b58..5a09fde370f 100644 --- a/solana-programs/claimable-tokens/cli/src/main.rs +++ b/solana-programs/claimable-tokens/cli/src/main.rs @@ -4,16 +4,20 @@ use std::ops::Mul; use anyhow::anyhow; use anyhow::{bail, Context}; use borsh::BorshSerialize; -use claimable_tokens::state::{NonceAccount, TransferInstructionData}; +use claimable_tokens::state::{NonceAccount, TransferInstructionData, SignedSetAuthorityData}; use claimable_tokens::utils::program::{find_nonce_address, NONCE_ACCOUNT_PREFIX}; use claimable_tokens::{ - instruction::CreateTokenAccount, utils::program::{find_address_pair, EthereumAddress}, }; +use claimable_tokens::instruction::{ + CreateTokenAccount, +}; use clap::{ crate_description, crate_name, crate_version, value_t, App, AppSettings, Arg, ArgMatches, SubCommand, }; +use solana_clap_utils::input_parsers::keypair_of; +use solana_clap_utils::input_validators::is_keypair; use solana_clap_utils::{ fee_payer::fee_payer_arg, input_parsers::pubkey_of, @@ -22,6 +26,9 @@ use solana_clap_utils::{ }; use solana_client::{rpc_client::RpcClient, rpc_response::Response}; use solana_program::instruction::Instruction; +use solana_program::program_option::COption; +use solana_sdk::signature::Keypair; +use solana_sdk::{secp256k1_instruction, bs58}; use solana_sdk::{ account::ReadableAccount, commitment_config::CommitmentConfig, @@ -203,6 +210,7 @@ fn send_to(config: Config, eth_address: [u8; 20], mint: Pubkey, amount: f64) -> // Checking if the derived address of recipient does not exist // then we must add instruction to create it let derived_token_acc_data = config.rpc_client.get_account_data(&pair.derive.address); + if derived_token_acc_data.is_err() { instructions.push(claimable_tokens::instruction::init( &claimable_tokens::id(), @@ -265,6 +273,108 @@ fn balance(config: Config, eth_address: EthereumAddress, mint: Pubkey) -> anyhow Ok(()) } +fn init_account( + config: Config, + eth_address: EthereumAddress, + mint: Pubkey, +) -> anyhow::Result<()> { + let instruction = claimable_tokens::instruction::init( + &claimable_tokens::id(), + &config.fee_payer.pubkey(), + &mint, + CreateTokenAccount { eth_address }, + )?; + let mut tx = + Transaction::new_with_payer(&[instruction], Some(&config.fee_payer.pubkey())); + let (recent_blockhash, _) = config.rpc_client.get_recent_blockhash()?; + tx.sign(&[config.fee_payer.as_ref()], recent_blockhash); + let tx_hash = config + .rpc_client + .send_and_confirm_transaction_with_spinner_and_config( + &tx, + config.rpc_client.commitment(), + solana_client::rpc_config::RpcSendTransactionConfig { + skip_preflight: true, + preflight_commitment: None, + encoding: None, + max_retries: None, + }, + )?; + println!("Init completed, transaction hash: {:?}", tx_hash); + Ok(()) +} + +fn set_authority( + config: Config, + eth_private_key: libsecp256k1::SecretKey, + mint: Pubkey, + new_authority: Pubkey, + authority_type: spl_token::instruction::AuthorityType, +) -> anyhow::Result<()> { + let eth_pubkey = libsecp256k1::PublicKey::from_secret_key(ð_private_key); + let eth_address = construct_eth_pubkey(ð_pubkey); + let pair = find_address_pair(&claimable_tokens::id(), &mint, eth_address)?; + + let (recent_blockhash, _) = config.rpc_client.get_recent_blockhash()?; + + let signed_instruction = spl_token::instruction::TokenInstruction::SetAuthority { + authority_type, + new_authority: COption::Some(new_authority), + }; + + let message = SignedSetAuthorityData { + blockhash: recent_blockhash, + instruction: signed_instruction.pack(), + account_pubkey: pair.derive.address, + }; + + let signature_instr = secp256k1_instruction::new_secp256k1_instruction( + ð_private_key, + &message.try_to_vec().unwrap(), + ); + let set_authority_instruction = claimable_tokens::instruction::set_authority( + &claimable_tokens::id(), + &pair.derive.address, + &pair.base.address, + )?; + let mut tx = + Transaction::new_with_payer(&[signature_instr, set_authority_instruction], Some(&config.fee_payer.pubkey())); + tx.sign( + &[config.fee_payer.as_ref(), config.owner.as_ref()], + recent_blockhash, + ); + let tx_hash = config + .rpc_client + .send_and_confirm_transaction_with_spinner(&tx)?; + println!("Set authority completed, transaction hash: {:?}", tx_hash); + Ok(()) +} + +fn close( + config: Config, + eth_address: EthereumAddress, + mint: Pubkey, + destination_account: Pubkey, +) -> anyhow::Result<()> { + let pair = find_address_pair(&claimable_tokens::id(), &mint, eth_address)?; + let instruction = claimable_tokens::instruction::close( + &claimable_tokens::id(), + &pair.derive.address, + &pair.base.address, + &destination_account, + eth_address + )?; + let mut tx = + Transaction::new_with_payer(&[instruction], Some(&config.fee_payer.pubkey())); + let (recent_blockhash, _) = config.rpc_client.get_recent_blockhash()?; + tx.sign(&[config.fee_payer.as_ref()], recent_blockhash); + let tx_hash = config + .rpc_client + .send_and_confirm_transaction_with_spinner(&tx)?; + println!("Close completed, transaction hash: {:?}", tx_hash); + Ok(()) +} + fn main() -> anyhow::Result<()> { let matches = App::new(crate_name!()) .about(crate_description!()) @@ -383,6 +493,65 @@ fn main() -> anyhow::Result<()> { .help("Program Id address"), ]) .help("Receives balance of account that associated with Ethereum address and specific mint."), + SubCommand::with_name("init").args(&[ + Arg::with_name("eth_address") + .value_name("ETHEREUM_ADDRESS") + .takes_value(true) + .required(true) + .help("Ethereum address to create token account for"), + Arg::with_name("mint") + .validator(is_pubkey) + .value_name("MINT_ADDRESS") + .takes_value(true) + .required(true) + .help("Token mint address"), + ]) + .help("Create a token account for the specified Ethereum address and mint."), + SubCommand::with_name("set-authority").args(&[ + Arg::with_name("private_key") + .value_name("ETHEREUM_PRIVATE_KEY") + .takes_value(true) + .required(true) + .help("Ethereum private key associated with the token account to change authority of."), + Arg::with_name("mint") + .validator(is_pubkey) + .value_name("MINT_ADDRESS") + .takes_value(true) + .required(true) + .help("Token mint address"), + Arg::with_name("new_authority") + .validator(is_pubkey) + .value_name("NEW_AUTHORITY") + .takes_value(true) + .required(true) + .help("New authority to set for the token account."), + Arg::with_name("authority_type") + .value_name("AUTHORITY_TYPE") + .takes_value(true) + .required(true) + .help("Type of authority to set for the token account."), + ]) + .help("Set a new authority for the token account associated with the specified Ethereum address and mint."), + SubCommand::with_name("close").args(&[ + Arg::with_name("eth_address") + .value_name("ETHEREUM_ADDRESS") + .takes_value(true) + .required(true) + .help("Ethereum address to close token account for"), + Arg::with_name("mint") + .validator(is_pubkey) + .value_name("MINT_ADDRESS") + .takes_value(true) + .required(true) + .help("Token mint address"), + Arg::with_name("rent_destination") + .validator(is_pubkey) + .value_name("RENT_DESTINATION") + .takes_value(true) + .required(true) + .help("Where to return the rent to when the account is closed."), + ]) + .help("Close the token account associated with the specified Ethereum address and mint."), ]) .get_matches(); @@ -478,6 +647,54 @@ fn main() -> anyhow::Result<()> { })() .context("Preparing parameters for execution command `send to`")?; } + ("init", Some(args)) => { + let (mint, eth_address) = (|| -> anyhow::Result<_> { + let eth_address = eth_address_of(args, "eth_address")?; + let mint = pubkey_of(args, "mint").unwrap(); + + Ok((mint, eth_address)) + })() + .context("Preparing parameters for execution command `init`")?; + + init_account(config, eth_address, mint) + .context("Failed to execute `init` command")? + } + ("set-authority", Some(args)) => { + let (eth_private_key, mint, new_authority, authority_type) = (|| -> anyhow::Result<_> { + let eth_private_key = eth_seckey_of(args, "private_key")?; + let mint = pubkey_of(args, "mint").unwrap(); + let new_authority = pubkey_of(args, "new_authority").unwrap(); + let authority_type_arg = args.value_of("authority_type").unwrap(); + if authority_type_arg != "AccountOwner" && authority_type_arg != "CloseAccount" { + bail!("Invalid authority type provided. Must be either 'AccountOwner' or 'CloseAccount'"); + } + let authority_type = match authority_type_arg { + "AccountOwner" => spl_token::instruction::AuthorityType::AccountOwner, + "CloseAccount" => spl_token::instruction::AuthorityType::CloseAccount, + _ => unreachable!(), + }; + + Ok((eth_private_key, mint, new_authority, authority_type)) + })() + .context("Preparing parameters for execution command `set-authority`")?; + + set_authority(config, eth_private_key, mint, new_authority, authority_type) + .context("Failed to execute `set-authority` command")? + } + ("close", Some(args)) => { + let (eth_address, mint, rent_destination) = (|| -> anyhow::Result<_> { + let eth_address = eth_address_of(args, "eth_address")?; + let mint = pubkey_of(args, "mint").unwrap(); + let rent_destination = pubkey_of(args, "rent_destination").unwrap(); + + Ok((eth_address, mint, rent_destination)) + })() + .context("Preparing parameters for execution command `close`")?; + + close(config, eth_address, mint, rent_destination) + .context("Failed to execute `close` command")? + } + _ => unreachable!(), } Ok(()) diff --git a/solana-programs/claimable-tokens/program/src/error.rs b/solana-programs/claimable-tokens/program/src/error.rs index 2fbd4ac4dd5..96a2dd52920 100644 --- a/solana-programs/claimable-tokens/program/src/error.rs +++ b/solana-programs/claimable-tokens/program/src/error.rs @@ -24,6 +24,9 @@ pub enum ClaimableProgramError { /// User nonce verification error #[error("Nonce verification failed")] NonceVerificationError, + /// Invalid signature data + #[error("Invalid signature data")] + InvalidSignatureData, } impl From for ProgramError { fn from(e: ClaimableProgramError) -> Self { diff --git a/solana-programs/claimable-tokens/program/src/instruction.rs b/solana-programs/claimable-tokens/program/src/instruction.rs index a83954f090e..cd7b4b387fe 100644 --- a/solana-programs/claimable-tokens/program/src/instruction.rs +++ b/solana-programs/claimable-tokens/program/src/instruction.rs @@ -42,6 +42,24 @@ pub enum ClaimableProgramInstruction { /// 7. `[r]` System program id /// 8. `[r]` SPL token account id Transfer(EthereumAddress), + + /// Set authority + /// + /// 0. `[w]` Token acc to change owner of (bank account) + /// 1. `[r]` Banks token account authority (current owner) + /// 2. `[r]` Sysvar instruction id + /// 3. `[r]` Sysvar recent blockhashes id + /// 4. `[r]` SPL token account id + SetAuthority, + + /// Close token account + /// + /// 0. `[w]` Token acc to close + /// 1. `[r]` Token acc authority + /// 2. `[w]` Destination acc to receive rent + /// 3. `[r]` SPL token account id + /// 4. `[s]` Close authority (if different from token acc authority) + Close(EthereumAddress), } /// Create `CreateTokenAccount` instruction @@ -104,3 +122,52 @@ pub fn transfer( data, }) } + +/// Create `SetAuthority` instruction +/// +/// NOTE: Instruction must followed after `new_secp256k1_instruction` +/// with params: current owner ethereum private key and bank token account public key. +/// Otherwise error message `Secp256 instruction losing` will be issued +pub fn set_authority( + program_id: &Pubkey, + banks_token_acc: &Pubkey, + authority: &Pubkey, +) -> Result { + let data = ClaimableProgramInstruction::SetAuthority.try_to_vec()?; + let accounts = vec![ + AccountMeta::new(*banks_token_acc, false), + AccountMeta::new_readonly(*authority, false), + AccountMeta::new_readonly(sysvar::instructions::id(), false), + AccountMeta::new_readonly(sysvar::recent_blockhashes::id(), false), + AccountMeta::new_readonly(spl_token::id(), false), + ]; + Ok(Instruction { + program_id: *program_id, + accounts, + data, + }) +} + +/// Create `Close` instruction +pub fn close( + program_id: &Pubkey, + token_account: &Pubkey, + authority: &Pubkey, + destination_account: &Pubkey, + eth_address: EthereumAddress, +) -> Result { + let data = ClaimableProgramInstruction::Close(eth_address) + .try_to_vec() + .unwrap(); + let accounts = vec![ + AccountMeta::new(*token_account, false), + AccountMeta::new_readonly(*authority, false), + AccountMeta::new(*destination_account, false), + AccountMeta::new_readonly(spl_token::id(), false), + ]; + Ok(Instruction { + program_id: *program_id, + accounts, + data, + }) +} \ No newline at end of file diff --git a/solana-programs/claimable-tokens/program/src/processor.rs b/solana-programs/claimable-tokens/program/src/processor.rs index 3ce95bb9313..5b1d4dd449b 100644 --- a/solana-programs/claimable-tokens/program/src/processor.rs +++ b/solana-programs/claimable-tokens/program/src/processor.rs @@ -3,7 +3,7 @@ use crate::{ error::{to_claimable_tokens_error, ClaimableProgramError}, instruction::ClaimableProgramInstruction, - state::{NonceAccount, TransferInstructionData}, + state::{NonceAccount, TransferInstructionData, SignedSetAuthorityData}, utils::program::{ find_address_pair, find_nonce_address, EthereumAddress, NONCE_ACCOUNT_PREFIX, }, @@ -17,18 +17,25 @@ use solana_program::{ program_error::ProgramError, program_pack::Pack, pubkey::Pubkey, - secp256k1_program, system_instruction, sysvar, + secp256k1_program, system_instruction, sysvar::{self, recent_blockhashes::RecentBlockhashes}, sysvar::rent::Rent, sysvar::Sysvar, }; use std::mem::size_of; +/// Pubkey length +pub const PUBKEY_LENGTH: usize = 32; + /// Known const for serialized signature offsets pub const SIGNATURE_OFFSETS_SERIALIZED_SIZE: usize = 11; /// Start of SECP recovery data after serialized SecpSignatureOffsets struct pub const DATA_START: usize = SIGNATURE_OFFSETS_SERIALIZED_SIZE + 1; +/// Default rent destination for closing token accounts +/// Prod/stage: 2HYDf9XvHRKhquxK1z4ETJ8ywueZcqEazyFZdRfLqGcT +pub const DEFAULT_RENT_DESTINATION: &str = "2HYDf9XvHRKhquxK1z4ETJ8ywueZcqEazyFZdRfLqGcT"; + /// Secp256k1 signature offsets data #[derive(Clone, Copy, Debug, Default, PartialEq, BorshDeserialize, BorshSerialize)] pub struct SecpSignatureOffsets { @@ -48,6 +55,13 @@ pub struct SecpSignatureOffsets { pub message_instruction_index: u8, } + +const ETH_ADDRESS_OFFSET: usize = 12; +// signature_offset = ETH_ADDRESS_OFFSET (12) + eth_pubkey.len (20) = 32 +const SIGNATURE_OFFSET: usize = 32; +// ETH_ADDRESS_OFFSET (12) + address (20) + signature (65) = 97 +const MESSAGE_DATA_OFFSET: usize = 97; + /// Program state handler. pub struct Processor; @@ -172,6 +186,33 @@ impl Processor { eth_address, ) } + ClaimableProgramInstruction::SetAuthority => { + msg!("Instruction: SetAuthority"); + let token_account_info = next_account_info(account_info_iter)?; + let authority_account_info = next_account_info(account_info_iter)?; + let sysvars_instruction_info = next_account_info(account_info_iter)?; + let recent_blockhashes_account_info = next_account_info(account_info_iter)?; + Self::process_set_authority_instruction( + program_id, + token_account_info.clone(), + authority_account_info.clone(), + sysvars_instruction_info.clone(), + recent_blockhashes_account_info.clone(), + ) + } + ClaimableProgramInstruction::Close(eth_address) => { + msg!("Instruction: Close"); + let token_account_info = next_account_info(account_info_iter)?; + let authority_account_info = next_account_info(account_info_iter)?; + let destination_account_info = next_account_info(account_info_iter)?; + Self::process_close_instruction( + program_id, + token_account_info.clone(), + authority_account_info.clone(), + destination_account_info.clone(), + eth_address, + ) + } } } @@ -298,6 +339,169 @@ impl Processor { ) } + fn process_set_authority_instruction<'a>( + program_id: &Pubkey, + token_account_info: AccountInfo<'a>, + authority_account_info: AccountInfo<'a>, + sysvars_instruction_info: AccountInfo<'a>, + recent_blockhashes_account_info: AccountInfo<'a>, + ) -> ProgramResult { + let index = sysvar::instructions::load_current_index_checked(&sysvars_instruction_info) + .map_err(to_claimable_tokens_error)?; + + // instruction can't be first in transaction + // because must follow after `new_secp256k1_instruction` + if index == 0 { + msg!("Secp256k1 instruction missing"); + return Err(ClaimableProgramError::Secp256InstructionLosing.into()); + } + + // Current instruction - 1 + let secp_program_index = index - 1; + + // load previous instruction + let instruction = sysvar::instructions::load_instruction_at_checked( + secp_program_index as usize, + &sysvars_instruction_info, + ) + .map_err(to_claimable_tokens_error)?; + + // is that instruction is `new_secp256k1_instruction` + if instruction.program_id != secp256k1_program::id() { + msg!("Incorrect program id for secp256k1 instruction"); + return Err(ClaimableProgramError::Secp256InstructionLosing.into()); + } + + Self::validate_secp_instruction_offsets(instruction.data.clone(), secp_program_index as u8)?; + + // Parse the secp256k1 instruction + let offsets = SecpSignatureOffsets::try_from_slice( + &instruction.data[1..(1 + SIGNATURE_OFFSETS_SERIALIZED_SIZE)], + )?; + let eth_address_signer = &instruction.data + [offsets.eth_address_offset as usize..(offsets.eth_address_offset as usize + size_of::())]; + let message = &instruction.data + [offsets.message_data_offset as usize + ..(offsets.message_data_offset + offsets.message_data_size) as usize + ]; + + // Deserialize the message into the SetAuthority instruction data + let signed_data = SignedSetAuthorityData::try_from_slice(message) + .map_err(|_| ClaimableProgramError::InvalidSignatureData)?; + + // Verify the blockhash is recent to prevent replay attacks + let recent_blockhashes = RecentBlockhashes::from_account_info(&recent_blockhashes_account_info) + .map_err(|_| ClaimableProgramError::InvalidSignatureData)?; + + let blockhash_found = recent_blockhashes + .iter() + .any(|entry| entry.blockhash == signed_data.blockhash); + + if !blockhash_found { + msg!("Blockhash is not recent"); + return Err(ClaimableProgramError::InvalidSignatureData.into()); + } + + // Check that the account being acted on matches the signed account + if *token_account_info.key != signed_data.account_pubkey { + msg!("Token account mismatch"); + return Err(ClaimableProgramError::InvalidSignatureData.into()); + } + + // Deserialize the signed instruction data + let signed_instruction = spl_token::instruction::TokenInstruction::unpack(&signed_data.instruction) + .map_err(|_| ClaimableProgramError::InvalidSignatureData)?; + + // Ensure it's a SetAuthority instruction + let signed_data = match signed_instruction { + spl_token::instruction::TokenInstruction::SetAuthority { + ref authority_type, ref new_authority + } => { + (authority_type, new_authority) + } + _ => { + msg!("Incorrect token instruction in signed message"); + return Err(ClaimableProgramError::InvalidSignatureData.into()); + } + }; + + // Extract authority type and new authority from the signed data + let authority_type = signed_data.0.clone(); + let new_authority = signed_data.1.ok_or(ClaimableProgramError::InvalidSignatureData)?; + + // Verify Secp256k1 signer derives to the matching token account address + // and authority PDA address. + let signer_address = EthereumAddress::try_from_slice(eth_address_signer) + .map_err(|_| ClaimableProgramError::SignatureVerificationFailed)?; + let mint = &spl_token::state::Account::unpack(&token_account_info.data.borrow())?.mint; + let pair = find_address_pair(program_id, mint, signer_address)?; + let derived_authority = pair.base.address; + let derived_token_account = pair.derive.address; + if *authority_account_info.key != derived_authority { + msg!("Authority account mismatch"); + return Err(ClaimableProgramError::SignatureVerificationFailed.into()); + } + if *token_account_info.key != derived_token_account { + msg!("Token account mismatch"); + return Err(ClaimableProgramError::SignatureVerificationFailed.into()); + } + + // Set the token authority + invoke_signed( + &spl_token::instruction::set_authority( + &spl_token::id(), + token_account_info.key, + Some(&new_authority), + authority_type, + authority_account_info.key, + &[authority_account_info.key], + )?, + &[token_account_info, authority_account_info], + &[&[&mint.to_bytes()[..32], &[pair.base.seed]][..]], + ) + } + + fn process_close_instruction<'a>( + program_id: &Pubkey, + token_account_info: AccountInfo<'a>, + authority_account_info: AccountInfo<'a>, + destination_account_info: AccountInfo<'a>, + eth_address: EthereumAddress, + ) -> Result<(), ProgramError> { + let token_account_data = spl_token::state::Account::unpack(&token_account_info.data.borrow())?; + let mint = &token_account_data.mint; + let pair = find_address_pair(program_id, mint, eth_address)?; + let seed_slice = [&mint.to_bytes()[..32], &[pair.base.seed]]; + let seeds = &[&seed_slice[..]]; + + if token_account_info.key != &pair.derive.address { + msg!("Token account mismatch"); + return Err(ProgramError::InvalidSeeds); + } + + if authority_account_info.key != &pair.base.address { + msg!("Authority account mismatch"); + return Err(ProgramError::InvalidSeeds); + } + + if destination_account_info.key.to_string() != DEFAULT_RENT_DESTINATION { + msg!("Destination account mismatch"); + return Err(ProgramError::InvalidAccountData); + } + invoke_signed( + &spl_token::instruction::close_account( + &spl_token::id(), + token_account_info.key, + destination_account_info.key, + authority_account_info.key, + &[authority_account_info.key], + )?, + &[token_account_info, destination_account_info, authority_account_info], + seeds, + ) + + } + /// Checks that the user signed message with his ethereum private key fn check_ethereum_sign<'a>( program_id: &Pubkey, @@ -426,23 +630,41 @@ impl Processor { secp_instruction_data: Vec, instruction_index: u8, ) -> Result { + Self::validate_secp_instruction_offsets(secp_instruction_data.clone(), instruction_index)?; + let instruction_signer = secp_instruction_data + [ETH_ADDRESS_OFFSET..ETH_ADDRESS_OFFSET + size_of::()] + .to_vec(); + if instruction_signer != expected_signer { + return Err(ClaimableProgramError::SignatureVerificationFailed.into()); + } + + let instruction_message = secp_instruction_data[MESSAGE_DATA_OFFSET..].to_vec(); + let decoded_instr_data = + TransferInstructionData::try_from_slice(&instruction_message).unwrap(); + + if decoded_instr_data.target_pubkey.to_bytes() != *expected_message { + return Err(ClaimableProgramError::SignatureVerificationFailed.into()); + } + + Ok(decoded_instr_data) + } + + fn validate_secp_instruction_offsets( + secp_instruction_data: Vec, + instruction_index: u8, + ) -> Result<(), ProgramError> { // Only single recovery expected if secp_instruction_data[0] != 1 { return Err(ClaimableProgramError::SignatureVerificationFailed.into()); } - // Assert instruction_index = 1 + // Get the first (only) set of offsets let start = 1; let end = start + (SIGNATURE_OFFSETS_SERIALIZED_SIZE as usize); let sig_offsets_struct = SecpSignatureOffsets::try_from_slice(&secp_instruction_data[start..end]) .map_err(|_| ClaimableProgramError::SignatureVerificationFailed)?; - let eth_address_offset = 12; - // signature_offset = eth_address_offset (12) + eth_pubkey.len (20) = 32 - let signature_offset = 32; - // eth_address_offset (12) + address (20) + signature (65) = 97 - let message_data_offset = 97; // Validate the index of this instruction matches expected value if sig_offsets_struct.message_instruction_index != instruction_index @@ -453,28 +675,12 @@ impl Processor { } // Validate each offset is as expected - if sig_offsets_struct.eth_address_offset != (eth_address_offset as u16) - || sig_offsets_struct.signature_offset != (signature_offset as u16) - || sig_offsets_struct.message_data_offset != (message_data_offset as u16) + if sig_offsets_struct.eth_address_offset != (ETH_ADDRESS_OFFSET as u16) + || sig_offsets_struct.signature_offset != (SIGNATURE_OFFSET as u16) + || sig_offsets_struct.message_data_offset != (MESSAGE_DATA_OFFSET as u16) { return Err(ClaimableProgramError::SignatureVerificationFailed.into()); } - - let instruction_signer = secp_instruction_data - [eth_address_offset..eth_address_offset + size_of::()] - .to_vec(); - if instruction_signer != expected_signer { - return Err(ClaimableProgramError::SignatureVerificationFailed.into()); - } - - let instruction_message = secp_instruction_data[message_data_offset..].to_vec(); - let decoded_instr_data = - TransferInstructionData::try_from_slice(&instruction_message).unwrap(); - - if decoded_instr_data.target_pubkey.to_bytes() != *expected_message { - return Err(ClaimableProgramError::SignatureVerificationFailed.into()); - } - - Ok(decoded_instr_data) + Ok(()) } } diff --git a/solana-programs/claimable-tokens/program/src/state.rs b/solana-programs/claimable-tokens/program/src/state.rs index 390e528af81..23c5118945c 100644 --- a/solana-programs/claimable-tokens/program/src/state.rs +++ b/solana-programs/claimable-tokens/program/src/state.rs @@ -4,7 +4,7 @@ use solana_program::{ msg, program_error::ProgramError, program_pack::{IsInitialized, Pack, Sealed}, - pubkey::Pubkey, + pubkey::Pubkey, hash::Hash, }; /// Transfer instruction data @@ -19,6 +19,17 @@ pub struct TransferInstructionData { pub nonce: u64, } +/// The message structure for signing a SetAuthority instruction +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq)] +pub struct SignedSetAuthorityData { + /// A recent blockhash to prevent replays + pub blockhash: Hash, + /// The instruction data as a serialized byte array + pub instruction: Vec, + /// The account to act on behalf of + pub account_pubkey: Pubkey, +} + /// Current program version pub const PROGRAM_VERSION: u8 = 1; diff --git a/solana-programs/claimable-tokens/program/tests/tests.rs b/solana-programs/claimable-tokens/program/tests/tests.rs index 36fb23dc41a..fbfdec4144a 100644 --- a/solana-programs/claimable-tokens/program/tests/tests.rs +++ b/solana-programs/claimable-tokens/program/tests/tests.rs @@ -329,6 +329,154 @@ async fn init_instruction() { assert_eq!(token_account.mint, mint_account.pubkey()); } +// Test SetAuthority instruction signed by the ethereum key for the token account +#[tokio::test] +async fn set_authority_instruction() { + let mut program_context = program_test().start_with_context().await; + let rent = program_context.banks_client.get_rent().await.unwrap(); + let ( + _rng, + _key, + priv_key, + _secp_pubkey, + mint_account, + mint_authority, + _user_token_account, + eth_address, + ) = init_test_variables(); + + // prepare transfer creates mint, PDA and mints tokens to PDA (so token account exists) + let (base_acc, derive_acc, _tokens_amount) = prepare_transfer( + &mut program_context, + mint_account, + rent, + mint_authority, + eth_address, + &Keypair::new(), + ) + .await; + + // Choose a new authority pubkey to set + let new_authority = Keypair::new().pubkey(); + + // Build the SPL Token SetAuthority instruction bytes + use solana_program::program_option::COption; + let spl_set_auth = spl_token::instruction::TokenInstruction::SetAuthority { + authority_type: spl_token::instruction::AuthorityType::AccountOwner, + new_authority: COption::Some(new_authority), + }; + let packed = spl_set_auth.pack(); + + // Construct SignedSetAuthorityData with recent blockhash + use claimable_tokens::state::SignedSetAuthorityData; + let signed = SignedSetAuthorityData { + blockhash: program_context.last_blockhash, + instruction: packed.to_vec(), + account_pubkey: derive_acc, + }; + let message = signed.try_to_vec().expect("serialize signed set authority"); + + // create secp instruction signing the message with the priv_key + let secp_inst = new_secp256k1_instruction(&priv_key, &message); + + // Program instruction to set authority + let set_auth_inst = instruction::set_authority(&id(), &derive_acc, &base_acc).unwrap(); + + // send transaction: secp first, then program instruction + let mut tx = Transaction::new_with_payer( + &[secp_inst, set_auth_inst], + Some(&program_context.payer.pubkey()), + ); + tx.sign(&[&program_context.payer], program_context.last_blockhash); + program_context + .banks_client + .process_transaction(tx) + .await + .expect("set_authority tx failed"); + + // Verify the token account's owner changed to new_authority + let token_account_data = get_account(&mut program_context, &derive_acc) + .await + .unwrap(); + let token_account = spl_token::state::Account::unpack(&token_account_data.data.as_slice()).unwrap(); + + assert_eq!(token_account.owner, new_authority); +} + + +// Ensure a signature for one token account cannot be used for another token account +#[tokio::test] +async fn set_authority_bound_to_specific_account() { + let mut program_context = program_test().start_with_context().await; + let rent = program_context.banks_client.get_rent().await.unwrap(); + let ( + _rng, + _key, + priv_key, + _secp_pubkey, + mint_account_a, + mint_authority_a, + _user_token_account_a, + eth_address, + ) = init_test_variables(); + + // Create a second mint and authority for the second token account + let mint_account_b = Keypair::new(); + let mint_authority_b = Keypair::new(); + + // prepare first token account (mint A) + let (_base_a, derive_a, _tokens_a) = prepare_transfer( + &mut program_context, + mint_account_a, + rent, + mint_authority_a, + eth_address, + &Keypair::new(), + ) + .await; + + // prepare second token account (mint B) for the SAME ethereum address + let (_base_b, derive_b, _tokens_b) = prepare_transfer( + &mut program_context, + mint_account_b, + rent, + mint_authority_b, + eth_address, + &Keypair::new(), + ) + .await; + + // Build SPL SetAuthority payload that is explicitly bound to derive_a + use solana_program::program_option::COption; + let new_authority = Keypair::new().pubkey(); + let spl_set_auth = spl_token::instruction::TokenInstruction::SetAuthority { + authority_type: spl_token::instruction::AuthorityType::AccountOwner, + new_authority: COption::Some(new_authority), + }; + let packed = spl_set_auth.pack(); + + use claimable_tokens::state::SignedSetAuthorityData; + let signed = SignedSetAuthorityData { + blockhash: program_context.last_blockhash, + instruction: packed.to_vec(), + account_pubkey: derive_a, // bound to account A + }; + let message = signed.try_to_vec().expect("serialize signed set authority"); + + // create secp instruction signing the message with the priv_key + let secp_inst = new_secp256k1_instruction(&priv_key, &message); + + // But call set_authority for derive_b (account B) -- should fail + let set_auth_inst = instruction::set_authority(&id(), &derive_b, &find_address_pair(&id(), &derive_b, eth_address).unwrap().base.address).unwrap(); + + let mut tx = Transaction::new_with_payer(&[secp_inst, set_auth_inst], Some(&program_context.payer.pubkey())); + tx.sign(&[&program_context.payer], program_context.last_blockhash); + let res = program_context.banks_client.process_transaction(tx).await; + + // Expect InvalidSignatureData at instruction index 1 + assert_custom_error(res, 1, ClaimableProgramError::InvalidSignatureData); +} + // Verify that someone cannot block an account creation #[tokio::test] async fn create_account_with_seed_denial() { @@ -912,15 +1060,16 @@ async fn transfer_replay_instruction() { let mut transaction = Transaction::new_with_payer(&instructions, Some(&program_context.payer.pubkey())); - transaction.sign(&[&program_context.payer], program_context.last_blockhash); + let recent_blockhash = program_context.last_blockhash; + transaction.sign(&[&program_context.payer], recent_blockhash); program_context .banks_client .process_transaction(transaction) .await .unwrap(); - let final_user_nonce = get_user_account_nonce(&mut program_context, &nonce_account).await; - assert_eq!(transfer_instr_data.nonce + 1, final_user_nonce); + let user_nonce = get_user_account_nonce(&mut program_context, &nonce_account).await; + assert_eq!(transfer_instr_data.nonce + 1, user_nonce); let bank_token_account_data = get_account(&mut program_context, &user_bank_account) .await @@ -937,19 +1086,53 @@ async fn transfer_replay_instruction() { spl_token::state::Account::unpack(&user_token_account_data.data.as_slice()).unwrap(); assert_eq!(user_token_account.amount, transfer_amount); + + // Replay the same transaction with the same blockhash and signature let mut transaction2 = Transaction::new_with_payer(&instructions, Some(&program_context.payer.pubkey())); - let recent_blockhash = program_context + transaction2.sign(&[&program_context.payer], recent_blockhash); + program_context + .banks_client + .process_transaction(transaction2) + .await; + + let user_nonce_after_replay = get_user_account_nonce(&mut program_context, &nonce_account).await; + // Nonce should not have changed + assert_eq!(user_nonce, user_nonce_after_replay); + + let bank_token_account_data = get_account(&mut program_context, &user_bank_account) + .await + .unwrap(); + let bank_token_account_after_replay = + spl_token::state::Account::unpack(&bank_token_account_data.data.as_slice()).unwrap(); + // Balance should not have changed + assert_eq!(bank_token_account.amount, bank_token_account_after_replay.amount); + + // Replay again with a new blockhash to make the signature change + let mut new_recent_blockhash = program_context .banks_client .get_recent_blockhash() .await .unwrap(); - transaction2.sign(&[&program_context.payer], recent_blockhash); - let tx_result = program_context + while new_recent_blockhash == recent_blockhash { + // wait until blockhash changes + tokio::time::sleep(std::time::Duration::from_millis(400)).await; + new_recent_blockhash = program_context + .banks_client + .get_recent_blockhash() + .await + .unwrap(); + } + let mut transaction3 = + Transaction::new_with_payer(&instructions, Some(&program_context.payer.pubkey())); + transaction3.sign(&[&program_context.payer], new_recent_blockhash); + let final_tx_result = program_context .banks_client - .process_transaction(transaction2) + .process_transaction(transaction3) .await; - assert_custom_error(tx_result, 1, ClaimableProgramError::NonceVerificationError); + + assert_custom_error(final_tx_result, 1, ClaimableProgramError::NonceVerificationError); + } // Verify that someone cannot cause a transfer denial by sending lamports to a nonce account diff --git a/solana-programs/reward-manager/cli/src/main.rs b/solana-programs/reward-manager/cli/src/main.rs index 5333ba58465..c9f06848dfd 100644 --- a/solana-programs/reward-manager/cli/src/main.rs +++ b/solana-programs/reward-manager/cli/src/main.rs @@ -502,9 +502,7 @@ fn command_transfer( &claimable_tokens::id(), &config.fee_payer.pubkey(), &token_account.mint, - claimable_tokens::instruction::CreateTokenAccount { - eth_address: decoded_recipient_address, - }, + claimable_tokens::instruction::CreateTokenAccount { eth_address: decoded_recipient_address }, )?); println!(