diff --git a/solana-programs/claimable-tokens/cli/src/main.rs b/solana-programs/claimable-tokens/cli/src/main.rs index 64fc07b8b58..5ca7d503681 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,107 @@ 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(), + }; + + 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 +492,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(false) + .help("New authority to set for the token account."), + Arg::with_name("authority_type") + .value_name("AUTHORITY_TYPE") + .takes_value(true) + .required(false) + .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 +646,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..1555c1bd33c 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 `TransferOwnership` 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 mut 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..11b1233c898 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 { @@ -172,6 +179,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 +332,161 @@ 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()); + } + + // 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()); + } + + // 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, diff --git a/solana-programs/claimable-tokens/program/src/state.rs b/solana-programs/claimable-tokens/program/src/state.rs index 390e528af81..d471d79e33b 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,15 @@ 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, +} + /// 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..c1685aec1ea 100644 --- a/solana-programs/claimable-tokens/program/tests/tests.rs +++ b/solana-programs/claimable-tokens/program/tests/tests.rs @@ -912,15 +912,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 +938,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!(