diff --git a/Cargo.toml b/Cargo.toml index b653d48..9644873 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ incremental = false codegen-units = 1 [workspace.dependencies] -anchor-lang = "0.30.1" +anchor-lang = { version = "0.30.1", features = ["init-if-needed"] } anchor-spl = "0.30.1" anchor-client = "0.30.1" clap = { version = "3.2.25", features = ["derive", "env"] } diff --git a/programs/locker/src/errors.rs b/programs/locker/src/errors.rs index 9f5cee8..9f4aa77 100644 --- a/programs/locker/src/errors.rs +++ b/programs/locker/src/errors.rs @@ -62,4 +62,10 @@ pub enum LockerError { #[msg("Claiming is not finished")] ClaimingIsNotFinished, + + #[msg("Invalid merkle proof")] + InvalidMerkleProof, + + #[msg("Escrow is not cancelled")] + EscrowNotCancelled, } diff --git a/programs/locker/src/events.rs b/programs/locker/src/events.rs index 40144e0..4415e48 100644 --- a/programs/locker/src/events.rs +++ b/programs/locker/src/events.rs @@ -14,6 +14,14 @@ pub struct EventCreateVestingEscrow { pub escrow: Pubkey, } +#[event] +pub struct EventCreateVestingEscrowV3 { + pub total_deposit_amount: u64, + pub escrow: Pubkey, + pub cancel_mode: u8, + pub root: [u8; 32], +} + #[event] pub struct EventClaim { pub amount: u64, @@ -21,6 +29,20 @@ pub struct EventClaim { pub escrow: Pubkey, } +#[event] +pub struct EventClaimV3 { + pub amount: u64, + pub current_ts: u64, + pub escrow: Pubkey, + pub vesting_start_time: u64, + pub cliff_time: u64, + pub frequency: u64, + pub cliff_unlock_amount: u64, + pub amount_per_period: u64, + pub number_of_period: u64, + pub recipient: Pubkey +} + #[event] pub struct EventUpdateVestingEscrowRecipient { pub escrow: Pubkey, @@ -38,7 +60,23 @@ pub struct EventCancelVestingEscrow { pub cancelled_at: u64, } +#[event] +pub struct EventCancelVestingEscrowV3 { + pub escrow: Pubkey, + pub signer: Pubkey, + pub remaining_amount: u64, + pub cancelled_at: u64, +} + #[event] pub struct EventCloseVestingEscrow { pub escrow: Pubkey, } + + +#[event] +pub struct EventCloseClaimStatus { + pub escrow: Pubkey, + pub recipient: Pubkey, + pub rent_receiver: Pubkey +} \ No newline at end of file diff --git a/programs/locker/src/instructions/create_vesting_escrow_metadata.rs b/programs/locker/src/instructions/create_vesting_escrow_metadata.rs index eced150..eb4e24c 100644 --- a/programs/locker/src/instructions/create_vesting_escrow_metadata.rs +++ b/programs/locker/src/instructions/create_vesting_escrow_metadata.rs @@ -1,3 +1,5 @@ +use util::account_info_ref_lifetime_shortener; + use crate::*; #[derive(AnchorSerialize, AnchorDeserialize)] @@ -5,16 +7,15 @@ pub struct CreateVestingEscrowMetadataParameters { pub name: String, pub description: String, pub creator_email: String, - pub recipient_email: String, + pub recipient_endpoint: String, } /// Accounts for [locker::create_vesting_escrow_metadata]. #[derive(Accounts)] #[instruction(metadata: CreateVestingEscrowMetadataParameters)] pub struct CreateVestingEscrowMetadataCtx<'info> { - /// The [Escrow]. - #[account(mut, has_one = creator)] - pub escrow: AccountLoader<'info, VestingEscrow>, + /// CHECK: The [Escrow] read-only, used to validate the escrow's creator + pub escrow: AccountInfo<'info>, /// Creator of the escrow. pub creator: Signer<'info>, /// The [ProposalMeta]. @@ -36,15 +37,50 @@ pub struct CreateVestingEscrowMetadataCtx<'info> { pub system_program: Program<'info, System>, } +impl CreateVestingEscrowMetadataCtx<'_> { + pub fn validate_escrow_creator(&self) -> Result<()> { + + // First try VestingEscrow + if let Ok(escrow) = AccountLoader::::try_from( + account_info_ref_lifetime_shortener(&self.escrow.to_account_info()), + ) { + require_keys_eq!( + escrow.load()?.creator, + self.creator.key(), + LockerError::NotPermitToDoThisAction + ); + + return Ok(()); + } + + // Otherwise, attempt to load VestingEscrowV3 + if let Ok(escrow) = AccountLoader::::try_from( + account_info_ref_lifetime_shortener(&self.escrow.to_account_info()), + ) { + require_keys_eq!( + escrow.load()?.creator, + self.creator.key(), + LockerError::NotPermitToDoThisAction + ); + + return Ok(()); + } + + err!(LockerError::NotPermitToDoThisAction) + } +} + pub fn handle_create_vesting_escrow_metadata( ctx: Context, params: &CreateVestingEscrowMetadataParameters, ) -> Result<()> { + ctx.accounts.validate_escrow_creator()?; + let escrow_metadata = &mut ctx.accounts.escrow_metadata; escrow_metadata.escrow = ctx.accounts.escrow.key(); escrow_metadata.name = params.name.clone(); escrow_metadata.description = params.description.clone(); escrow_metadata.creator_email = params.creator_email.clone(); - escrow_metadata.recipient_email = params.recipient_email.clone(); + escrow_metadata.recipient_endpoint = params.recipient_endpoint.clone(); Ok(()) } diff --git a/programs/locker/src/instructions/mod.rs b/programs/locker/src/instructions/mod.rs index 53d5053..7b0a209 100644 --- a/programs/locker/src/instructions/mod.rs +++ b/programs/locker/src/instructions/mod.rs @@ -10,3 +10,5 @@ pub mod v2; pub use v2::*; pub mod close_vesting_escrow; pub use close_vesting_escrow::*; +pub mod v3; +pub use v3::*; diff --git a/programs/locker/src/instructions/update_vesting_escrow_recipient.rs b/programs/locker/src/instructions/update_vesting_escrow_recipient.rs index ae633b2..ab348ce 100644 --- a/programs/locker/src/instructions/update_vesting_escrow_recipient.rs +++ b/programs/locker/src/instructions/update_vesting_escrow_recipient.rs @@ -26,7 +26,7 @@ pub struct UpdateVestingEscrowRecipientCtx<'info> { pub fn handle_update_vesting_escrow_recipient( ctx: Context, new_recipient: Pubkey, - new_recipient_email: Option, + new_recipient_endpoint: Option, ) -> Result<()> { let mut escrow = ctx.accounts.escrow.load_mut()?; let old_recipient = escrow.recipient; @@ -34,7 +34,7 @@ pub fn handle_update_vesting_escrow_recipient( escrow.validate_update_actor(signer)?; escrow.update_recipient(new_recipient); - if let Some(recipient_email) = new_recipient_email { + if let Some(recipient_endpoint) = new_recipient_endpoint { if let Some(escrow_metadata) = &mut ctx.accounts.escrow_metadata { require!( escrow_metadata.escrow == ctx.accounts.escrow.key(), @@ -46,7 +46,7 @@ pub fn handle_update_vesting_escrow_recipient( name: escrow_metadata.name.clone(), description: escrow_metadata.description.clone(), creator_email: escrow_metadata.creator_email.clone(), - recipient_email: recipient_email.clone(), + recipient_endpoint: recipient_endpoint.clone(), }); // update rent fee @@ -66,7 +66,7 @@ pub fn handle_update_vesting_escrow_recipient( // realloc escrow_metadata_info.realloc(new_len, false)?; // update new recipient_email - escrow_metadata.recipient_email = recipient_email; + escrow_metadata.recipient_endpoint = recipient_endpoint; } else { return Err(LockerError::InvalidEscrowMetadata.into()); } diff --git a/programs/locker/src/instructions/v3/cancel_vesting_escrow.rs b/programs/locker/src/instructions/v3/cancel_vesting_escrow.rs new file mode 100644 index 0000000..0959ac4 --- /dev/null +++ b/programs/locker/src/instructions/v3/cancel_vesting_escrow.rs @@ -0,0 +1,130 @@ +use crate::util::MemoTransferContext; +use crate::*; +use anchor_spl::memo::Memo; +use anchor_spl::token_interface::{ + close_account, CloseAccount, Mint, TokenAccount, TokenInterface, +}; +use util::{ + harvest_fees, parse_remaining_accounts, transfer_to_user_v3, AccountsType, + ParsedRemainingAccounts, TRANSFER_MEMO_CANCEL_VESTING, +}; + +/// Accounts for [locker::cancel_vesting_escrow_v3]. +#[derive(Accounts)] +#[event_cpi] +pub struct CancelVestingEscrowV3<'info> { + /// Escrow. + #[account( + mut, + has_one = token_mint, + constraint = escrow.load()?.cancelled_at == 0 @ LockerError::AlreadyCancelled + )] + pub escrow: AccountLoader<'info, VestingEscrowV3>, + + /// Mint. + #[account(mut)] + pub token_mint: Box>, + + /// Escrow Token Account. + #[account( + mut, + associated_token::mint = token_mint, + associated_token::authority = escrow, + associated_token::token_program = token_program + )] + pub escrow_token: Box>, + + /// Creator Token Account. + #[account(mut)] + pub creator_token: Box>, + + /// CHECKED: The Token Account will receive the rent + #[account(mut)] + pub rent_receiver: UncheckedAccount<'info>, + + /// Signer. + pub signer: Signer<'info>, + + /// Memo program. + pub memo_program: Program<'info, Memo>, + + /// Token program. + pub token_program: Interface<'info, TokenInterface>, +} + +impl<'info> CancelVestingEscrowV3<'info> { + fn close_escrow_token_v3(&self) -> Result<()> { + let escrow = self.escrow.load()?; + let escrow_seeds = escrow_seeds_v3!(escrow); + + close_account(CpiContext::new_with_signer( + self.token_program.to_account_info(), + CloseAccount { + account: self.escrow_token.to_account_info(), + destination: self.rent_receiver.to_account_info(), + authority: self.escrow.to_account_info(), + }, + &[&escrow_seeds[..]], + ))?; + + Ok(()) + } +} + +pub fn handle_cancel_vesting_escrow_v3<'c: 'info, 'info>( + ctx: Context<'_, '_, 'c, 'info, CancelVestingEscrowV3<'info>>, + remaining_accounts_info: Option, +) -> Result<()> { + let mut escrow = ctx.accounts.escrow.load_mut()?; + let signer = ctx.accounts.signer.key(); + escrow.validate_cancel_actor(signer)?; + + let current_ts = Clock::get()?.unix_timestamp as u64; + let remaining_amount = ctx.accounts.escrow_token.amount; + escrow.cancelled_at = current_ts; + require!(escrow.cancelled_at > 0, LockerError::CancelledAtIsZero); + drop(escrow); + + // Process remaining accounts + let mut remaining_accounts = &ctx.remaining_accounts[..]; + let parsed_transfer_hook_accounts = match remaining_accounts_info { + Some(info) => parse_remaining_accounts( + &mut remaining_accounts, + &info.slices, + &[AccountsType::TransferHookEscrow], + )?, + None => ParsedRemainingAccounts::default(), + }; + + // Transfer the remaining amount to the creator + transfer_to_user_v3( + &ctx.accounts.escrow, + &ctx.accounts.token_mint, + &ctx.accounts.escrow_token.to_account_info(), + &ctx.accounts.creator_token, + &ctx.accounts.token_program, + Some(MemoTransferContext { + memo_program: &ctx.accounts.memo_program, + memo: TRANSFER_MEMO_CANCEL_VESTING.as_bytes(), + }), + remaining_amount, + parsed_transfer_hook_accounts.transfer_hook_escrow, + )?; + + // Do fee harvesting + harvest_fees( + &ctx.accounts.token_program, + &ctx.accounts.escrow_token.to_account_info(), + &ctx.accounts.token_mint, + )?; + + ctx.accounts.close_escrow_token_v3()?; + + emit_cpi!(EventCancelVestingEscrowV3 { + escrow: ctx.accounts.escrow.key(), + signer, + remaining_amount, + cancelled_at: current_ts, + }); + Ok(()) +} diff --git a/programs/locker/src/instructions/v3/claim.rs b/programs/locker/src/instructions/v3/claim.rs new file mode 100644 index 0000000..6670bbf --- /dev/null +++ b/programs/locker/src/instructions/v3/claim.rs @@ -0,0 +1,210 @@ +use crate::util::{transfer_to_user_v3, MemoTransferContext}; +use crate::*; +use anchor_spl::memo::Memo; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use safe_math::SafeMath; +use solana_program::hash::hashv; +use util::{ + parse_remaining_accounts, AccountsType, ParsedRemainingAccounts, TRANSFER_MEMO_CLAIM_VESTING, +}; + +const LEAF_PREFIX: &[u8] = &[0]; +const INTERMEDIATE_PREFIX: &[u8] = &[1]; + +#[derive(AnchorSerialize, AnchorDeserialize)] +pub struct ClaimV3Params { + pub vesting_start_time: u64, + pub cliff_time: u64, + pub frequency: u64, + pub cliff_unlock_amount: u64, + pub amount_per_period: u64, + pub number_of_period: u64, + pub max_amount: u64, + pub proof: Vec<[u8; 32]>, +} + +impl ClaimV3Params { + pub fn verify_recipient(&self, recipient: Pubkey, root: [u8; 32]) -> Result<()> { + let node = hashv(&[ + &recipient.key().to_bytes(), + &self.cliff_unlock_amount.to_le_bytes(), + &self.amount_per_period.to_le_bytes(), + &self.number_of_period.to_le_bytes(), + &self.cliff_time.to_le_bytes(), + &self.frequency.to_le_bytes(), + &self.vesting_start_time.to_le_bytes(), + ]); + + let leaf = hashv(&[LEAF_PREFIX, &node.to_bytes()]); + + let mut computed_hash = leaf.to_bytes(); + for p in self.proof.iter() { + if computed_hash <= *p { + computed_hash = hashv(&[&INTERMEDIATE_PREFIX, &computed_hash, p]).to_bytes(); + } else { + computed_hash = hashv(&[&INTERMEDIATE_PREFIX, p, &computed_hash]).to_bytes(); + } + } + + require!(computed_hash == root, LockerError::InvalidMerkleProof); + + Ok(()) + } + + pub fn get_max_unlocked_amount(&self, current_ts: u64) -> Result { + if current_ts < self.cliff_time { + return Ok(0); + } + let period = current_ts + .safe_sub(self.cliff_time)? + .safe_div(self.frequency)?; + let period = period.min(self.number_of_period); + + let unlocked_amount = self + .cliff_unlock_amount + .safe_add(period.safe_mul(self.amount_per_period)?)?; + + Ok(unlocked_amount) + } + + pub fn get_claimable_amount(&self, total_claimed_amount: u64, current_ts: u64) -> Result { + let max_unlocked_amount = self.get_max_unlocked_amount(current_ts)?; + let claimable_amount = max_unlocked_amount.safe_sub(total_claimed_amount)?; + Ok(claimable_amount) + } + + pub fn get_claim_amount(&self, total_claimed_amount: u64) -> Result { + let current_ts = Clock::get()?.unix_timestamp as u64; + let claimable_amount = self.get_claimable_amount(total_claimed_amount, current_ts)?; + + let amount = claimable_amount.min(self.max_amount); + + Ok(amount) + } +} + +/// Accounts for [locker::claim_v3]. +#[event_cpi] +#[derive(Accounts)] +pub struct ClaimV3<'info> { + /// Claim status PDA + #[account( + init_if_needed, + seeds = [ + b"claim_status".as_ref(), + recipient.key().to_bytes().as_ref(), + escrow.key().to_bytes().as_ref() + ], + bump, + space = 8 + ClaimStatus::INIT_SPACE, + payer = payer + )] + pub claim_status: Account<'info, ClaimStatus>, + + /// Escrow. + #[account( + mut, + has_one = token_mint, + constraint = escrow.load()?.cancelled_at == 0 @ LockerError::AlreadyCancelled + )] + pub escrow: AccountLoader<'info, VestingEscrowV3>, + + /// Mint. + pub token_mint: Box>, + + /// Escrow Token Account. + #[account( + mut, + associated_token::mint = token_mint, + associated_token::authority = escrow, + associated_token::token_program = token_program + )] + pub escrow_token: Box>, + + /// Recipient. + pub recipient: Signer<'info>, + + #[account(mut)] + pub payer: Signer<'info>, + + /// Recipient Token Account. + #[account( + mut, + constraint = recipient_token.key() != escrow_token.key() @ LockerError::InvalidRecipientTokenAccount + )] + pub recipient_token: Box>, + + /// Memo program. + pub memo_program: Program<'info, Memo>, + + /// Token program. + pub token_program: Interface<'info, TokenInterface>, + /// system program. + pub system_program: Program<'info, System>, +} + +pub fn handle_claim_v3<'c: 'info, 'info>( + ctx: Context<'_, '_, 'c, 'info, ClaimV3<'info>>, + params: &ClaimV3Params, + remaining_accounts_info: Option, +) -> Result<()> { + let claim_status = &mut ctx.accounts.claim_status; + + // Fill `claim_status` if it hasn't been set yet + if claim_status.escrow == Pubkey::default() { + claim_status.recipient = ctx.accounts.recipient.key(); + claim_status.escrow = ctx.accounts.escrow.key(); + } + + let mut escrow = ctx.accounts.escrow.load_mut()?; + + params.verify_recipient(ctx.accounts.recipient.key(), escrow.root)?; + + let amount = params.get_claim_amount(claim_status.total_claimed_amount)?; + escrow.accumulate_claimed_amount(amount)?; + drop(escrow); + + // Process remaining accounts + let mut remaining_accounts = &ctx.remaining_accounts[..]; + let parsed_transfer_hook_accounts = match remaining_accounts_info { + Some(info) => parse_remaining_accounts( + &mut remaining_accounts, + &info.slices, + &[AccountsType::TransferHookEscrow], + )?, + None => ParsedRemainingAccounts::default(), + }; + + transfer_to_user_v3( + &ctx.accounts.escrow, + &ctx.accounts.token_mint, + &ctx.accounts.escrow_token.to_account_info(), + &ctx.accounts.recipient_token, + &ctx.accounts.token_program, + Some(MemoTransferContext { + memo_program: &ctx.accounts.memo_program, + memo: TRANSFER_MEMO_CLAIM_VESTING.as_bytes(), + }), + amount, + parsed_transfer_hook_accounts.transfer_hook_escrow, + )?; + + // update claim status + claim_status.accumulate_claimed_amount(amount)?; + + let current_ts = Clock::get()?.unix_timestamp as u64; + + emit_cpi!(EventClaimV3 { + amount, + current_ts, + recipient: ctx.accounts.recipient.key(), + escrow: ctx.accounts.escrow.key(), + vesting_start_time: params.vesting_start_time, + cliff_time: params.cliff_time, + frequency: params.frequency, + cliff_unlock_amount: params.cliff_unlock_amount, + amount_per_period: params.amount_per_period, + number_of_period: params.number_of_period, + }); + Ok(()) +} diff --git a/programs/locker/src/instructions/v3/close_claim_status.rs b/programs/locker/src/instructions/v3/close_claim_status.rs new file mode 100644 index 0000000..e66992d --- /dev/null +++ b/programs/locker/src/instructions/v3/close_claim_status.rs @@ -0,0 +1,32 @@ +use crate::*; + +/// Accounts for [locker::close_claim_status]. +#[derive(Accounts)] +#[event_cpi] +pub struct CloseClaimStatus<'info> { + /// Claim status + #[account( + mut, + has_one = recipient, + has_one = escrow, + close = rent_receiver + )] + pub claim_status: Account<'info, ClaimStatus>, + + #[account(constraint = escrow.load()?.cancelled_at > 0 @ LockerError::EscrowNotCancelled)] + pub escrow: AccountLoader<'info, VestingEscrowV3>, + + /// CHECKED: The system account will receive the rent + #[account(mut)] + pub rent_receiver: UncheckedAccount<'info>, + + /// recipient + #[account(mut)] + recipient: Signer<'info>, +} + +pub fn handle_close_claim_status<'c: 'info, 'info>( + _ctx: Context<'_, '_, 'c, 'info, CloseClaimStatus<'info>>, +) -> Result<()> { + Ok(()) +} diff --git a/programs/locker/src/instructions/v3/create_vesting_escrow.rs b/programs/locker/src/instructions/v3/create_vesting_escrow.rs new file mode 100644 index 0000000..ab51bdf --- /dev/null +++ b/programs/locker/src/instructions/v3/create_vesting_escrow.rs @@ -0,0 +1,160 @@ +use anchor_spl::token::spl_token; +use anchor_spl::token_2022::spl_token_2022; +use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; +use vesting_escrow_v3::VestingEscrowV3; + +use crate::util::{ + calculate_transfer_fee_included_amount, parse_remaining_accounts, transfer_to_escrow_v2, + validate_mint, AccountsType, ParsedRemainingAccounts, +}; +use crate::TokenProgramFlag::{UseSplToken, UseToken2022}; +use crate::*; + +#[derive(AnchorSerialize, AnchorDeserialize)] +/// Accounts for [locker::create_vesting_escrow_v3]. +pub struct CreateVestingEscrowV3Parameters { + pub total_deposit_amount: u64, + pub cancel_mode: u8, + pub root: [u8; 32], +} + +impl CreateVestingEscrowV3Parameters { + pub fn init_escrow( + &self, + vesting_escrow: &AccountLoader, + token_mint: Pubkey, + sender: Pubkey, + base: Pubkey, + total_deposit_amount: u64, + escrow_bump: u8, + token_program_flag: TokenProgramFlag, + ) -> Result<()> { + require!( + CancelMode::try_from(self.cancel_mode).is_ok(), + LockerError::InvalidCancelMode, + ); + + let mut vesting_escrow = vesting_escrow.load_init()?; + vesting_escrow.init( + token_mint, + sender, + base, + total_deposit_amount, + self.root, + self.cancel_mode, + escrow_bump, + token_program_flag.into(), + ); + + Ok(()) + } +} + +#[event_cpi] +#[derive(Accounts)] +pub struct CreateVestingEscrowV3<'info> { + pub base: Signer<'info>, + + /// Escrow. + #[account( + init, + seeds = [ + b"escrow_v3".as_ref(), + base.key().as_ref(), + ], + bump, + payer = sender, + space = 8 + VestingEscrowV3::INIT_SPACE + )] + pub escrow: AccountLoader<'info, VestingEscrowV3>, + + pub token_mint: Box>, + + /// Escrow Token Account. + #[account( + mut, + associated_token::mint = token_mint, + associated_token::authority = escrow, + associated_token::token_program = token_program + )] + pub escrow_token: Box>, + + /// Sender. + #[account(mut)] + pub sender: Signer<'info>, + + /// Sender Token Account. + #[account(mut)] + pub sender_token: Box>, + + /// Token program. + pub token_program: Interface<'info, TokenInterface>, + + /// system program. + pub system_program: Program<'info, System>, +} + +pub fn handle_create_vesting_escrow_v3<'c: 'info, 'info>( + ctx: Context<'_, '_, 'c, 'info, CreateVestingEscrowV3<'info>>, + params: &CreateVestingEscrowV3Parameters, + remaining_accounts_info: Option, +) -> Result<()> { + // Validate if token_mint is supported + validate_mint(&ctx.accounts.token_mint)?; + + let token_mint_info = ctx.accounts.token_mint.to_account_info(); + let token_program_flag = match *token_mint_info.owner { + spl_token::ID => Ok(UseSplToken), + spl_token_2022::ID => Ok(UseToken2022), + _ => Err(LockerError::IncorrectTokenProgramId), + }?; + + params.init_escrow( + &ctx.accounts.escrow, + ctx.accounts.sender_token.mint, + ctx.accounts.sender.key(), + ctx.accounts.base.key(), + params.total_deposit_amount, + ctx.bumps.escrow, + token_program_flag, + )?; + + // Process remaining accounts + let mut remaining_accounts = &ctx.remaining_accounts[..]; + let parsed_transfer_hook_accounts = match remaining_accounts_info { + Some(info) => parse_remaining_accounts( + &mut remaining_accounts, + &info.slices, + &[AccountsType::TransferHookEscrow], + )?, + None => ParsedRemainingAccounts::default(), + }; + + transfer_to_escrow_v2( + &ctx.accounts.sender, + &ctx.accounts.token_mint, + &ctx.accounts.sender_token, + &ctx.accounts.escrow_token, + &ctx.accounts.token_program, + calculate_transfer_fee_included_amount( + params.total_deposit_amount, + &ctx.accounts.token_mint, + )?, + parsed_transfer_hook_accounts.transfer_hook_escrow, + )?; + + let &CreateVestingEscrowV3Parameters { + root, + total_deposit_amount, + cancel_mode, + } = params; + + emit_cpi!(EventCreateVestingEscrowV3 { + total_deposit_amount, + escrow: ctx.accounts.escrow.key(), + cancel_mode, + root: root + }); + + Ok(()) +} diff --git a/programs/locker/src/instructions/v3/mod.rs b/programs/locker/src/instructions/v3/mod.rs new file mode 100644 index 0000000..65e1f3a --- /dev/null +++ b/programs/locker/src/instructions/v3/mod.rs @@ -0,0 +1,9 @@ +pub mod create_vesting_escrow; +pub mod claim; +pub mod cancel_vesting_escrow; +pub mod close_claim_status; + +pub use create_vesting_escrow::*; +pub use claim::*; +pub use cancel_vesting_escrow::*; +pub use close_claim_status::*; \ No newline at end of file diff --git a/programs/locker/src/lib.rs b/programs/locker/src/lib.rs index 6aadf19..e5f5305 100644 --- a/programs/locker/src/lib.rs +++ b/programs/locker/src/lib.rs @@ -140,6 +140,76 @@ pub mod locker { handle_claim_v2(ctx, max_amount, remaining_accounts_info) } + // V3 instructions + + /// Create a vesting escrow for the given params + /// This instruction supports both splToken and token2022 + /// # Arguments + /// + /// * ctx - The accounts needed by instruction. + /// * params - The params needed by instruction. + /// * total_deposit_amount - The total amount of all recipients will deposit to escrow + /// * root - 256 bit merkle tree root + /// * remaining_accounts_info: additional accounts needed by instruction + /// + pub fn create_vesting_escrow_v3<'c: 'info, 'info>( + ctx: Context<'_, '_, 'c, 'info, CreateVestingEscrowV3<'info>>, + params: CreateVestingEscrowV3Parameters, + remaining_accounts_info: Option, + ) -> Result<()> { + handle_create_vesting_escrow_v3(ctx, ¶ms, remaining_accounts_info) + } + + /// Claim maximum amount from the vesting escrow + /// This instruction supports both splToken and token2022 + /// # Arguments + /// * ctx - The accounts needed by instruction. + /// * params - The params needed by instruction. + /// * vesting_start_time - The creation time of this escrow + /// * cliff_time - Trade cliff time of the escrow + /// * frequency - How frequent the claimable amount will be updated + /// * cliff_unlock_amount - The amount unlocked after cliff time + /// * amount_per_period - The amount unlocked per vesting period + /// * number_of_period - The total number of vesting period + /// * max_amount - The maximum amount claimed by the recipient + /// * proof - merkle tree proof + /// * remaining_accounts_info: additional accounts needed by instruction + /// + pub fn claim_v3<'c: 'info, 'info>( + ctx: Context<'_, '_, 'c, 'info, ClaimV3<'info>>, + params: ClaimV3Params, + remaining_accounts_info: Option, + ) -> Result<()> { + handle_claim_v3(ctx, ¶ms, remaining_accounts_info) + } + + /// Close claim status account + /// Only allow close if escrow cancelled + /// Rent fee will be transferred to recipient + /// # Arguments + /// + /// * ctx - The accounts needed by instruction. + pub fn close_claim_status<'c: 'info, 'info>( + ctx: Context<'_, '_, 'c, 'info, CloseClaimStatus<'info>>, + ) -> Result<()> { + handle_close_claim_status(ctx) + } + + /// Cancel a vesting escrow v3 + /// - The rest of token will be transferred to the creator + /// This instruction supports both splToken and token2022 + /// # Arguments + /// + /// * ctx - The accounts needed by instruction. + /// * remaining_accounts_info: additional accounts needed by instruction + /// + pub fn cancel_vesting_escrow_v3<'c: 'info, 'info>( + ctx: Context<'_, '_, 'c, 'info, CancelVestingEscrowV3<'info>>, + remaining_accounts_info: Option, + ) -> Result<()> { + handle_cancel_vesting_escrow_v3(ctx, remaining_accounts_info) + } + /// Cancel a vesting escrow. /// - The claimable token will be transferred to recipient /// - The remaining token will be transferred to the creator diff --git a/programs/locker/src/macros.rs b/programs/locker/src/macros.rs index 483ec37..c748322 100644 --- a/programs/locker/src/macros.rs +++ b/programs/locker/src/macros.rs @@ -9,3 +9,13 @@ macro_rules! escrow_seeds { ] }; } + +macro_rules! escrow_seeds_v3 { + ($escrow:expr) => { + &[ + b"escrow_v3".as_ref(), + $escrow.base.as_ref(), + &[$escrow.escrow_bump], + ] + }; +} diff --git a/programs/locker/src/state/claim_status.rs b/programs/locker/src/state/claim_status.rs new file mode 100644 index 0000000..3ffd47a --- /dev/null +++ b/programs/locker/src/state/claim_status.rs @@ -0,0 +1,22 @@ +use crate::*; + +use safe_math::SafeMath; +#[account] +#[derive(Default, InitSpace, Debug)] +pub struct ClaimStatus { + /// total claimed amount of recipient + pub total_claimed_amount: u64, + /// escrow address + pub escrow: Pubkey, + /// recipient address + pub recipient: Pubkey, + /// buffer + pub buffer: [u128; 5], +} + +impl ClaimStatus { + pub fn accumulate_claimed_amount(&mut self, claimed_amount: u64) -> Result<()> { + self.total_claimed_amount = self.total_claimed_amount.safe_add(claimed_amount)?; + Ok(()) + } +} diff --git a/programs/locker/src/state/mod.rs b/programs/locker/src/state/mod.rs index 6444c25..53040f7 100644 --- a/programs/locker/src/state/mod.rs +++ b/programs/locker/src/state/mod.rs @@ -1,6 +1,10 @@ pub use vesting_escrow::*; pub use vesting_escrow_metadata::*; +pub use vesting_escrow_v3::*; +pub use claim_status::*; pub mod vesting_escrow; pub mod vesting_escrow_metadata; +pub mod vesting_escrow_v3; +pub mod claim_status; diff --git a/programs/locker/src/state/vesting_escrow_metadata.rs b/programs/locker/src/state/vesting_escrow_metadata.rs index 9f291ce..d52a010 100644 --- a/programs/locker/src/state/vesting_escrow_metadata.rs +++ b/programs/locker/src/state/vesting_escrow_metadata.rs @@ -12,8 +12,8 @@ pub struct VestingEscrowMetadata { pub description: String, /// Email of creator pub creator_email: String, - /// Email of recipient - pub recipient_email: String, + /// endpoint of recipient: email or api url. + pub recipient_endpoint: String, } impl VestingEscrowMetadata { @@ -27,6 +27,6 @@ impl VestingEscrowMetadata { + 4 + metadata.creator_email.as_bytes().len() + 4 - + metadata.recipient_email.as_bytes().len() + + metadata.recipient_endpoint.as_bytes().len() } } diff --git a/programs/locker/src/state/vesting_escrow_v3.rs b/programs/locker/src/state/vesting_escrow_v3.rs new file mode 100644 index 0000000..9ec94ce --- /dev/null +++ b/programs/locker/src/state/vesting_escrow_v3.rs @@ -0,0 +1,76 @@ +use static_assertions::const_assert_eq; + +use crate::*; +use safe_math::SafeMath; + +#[account(zero_copy)] +#[derive(Default, InitSpace, Debug)] +pub struct VestingEscrowV3 { + /// token mint + pub token_mint: Pubkey, + /// creator of the escrow + pub creator: Pubkey, + /// escrow base key + pub base: Pubkey, + /// 256 bit merkle root + pub root: [u8; 32], + /// cancel mode + pub cancel_mode: u8, + /// escrow bump + pub escrow_bump: u8, + /// token program flag + pub token_program_flag: u8, + /// padding + pub padding_0: [u8; 5], + /// total deposited amount + pub total_deposit_amount: u64, + /// total claimed amount of all recipients. + pub total_claimed_amount: u64, + /// cancelled_at + pub cancelled_at: u64, + /// buffer + pub buffer: [u128; 5], +} + +const_assert_eq!(VestingEscrowV3::INIT_SPACE, 240); // + +impl VestingEscrowV3 { + pub fn init( + &mut self, + token_mint: Pubkey, + sender: Pubkey, + base: Pubkey, + total_deposit_amount: u64, + root: [u8; 32], + cancel_mode: u8, + escrow_bump: u8, + token_program_flag: u8, + ) { + self.token_mint = token_mint; + self.creator = sender; + self.base = base; + self.total_deposit_amount = total_deposit_amount; + self.root = root; + self.cancel_mode = cancel_mode; + self.escrow_bump = escrow_bump; + self.token_program_flag = token_program_flag; + } + + pub fn accumulate_claimed_amount(&mut self, claimed_amount: u64) -> Result<()> { + self.total_claimed_amount = self.total_claimed_amount.safe_add(claimed_amount)?; + Ok(()) + } + + pub fn is_claimed_full_amount(&self) -> Result { + Ok(self.total_deposit_amount == self.total_claimed_amount) + } + + pub fn validate_cancel_actor(&self, signer: Pubkey) -> Result<()> { + // only creator has permission to cancel escrow v3 + require!(self.cancel_mode == 1, LockerError::NotPermitToDoThisAction); + + require_keys_eq!(signer, self.creator, LockerError::NotPermitToDoThisAction); + + Ok(()) + } +} diff --git a/programs/locker/src/util/common.rs b/programs/locker/src/util/common.rs index fad0017..3e7585f 100644 --- a/programs/locker/src/util/common.rs +++ b/programs/locker/src/util/common.rs @@ -19,3 +19,10 @@ pub fn close<'info>(info: AccountInfo<'info>, sol_destination: AccountInfo<'info pub fn is_closed(info: &AccountInfo) -> bool { info.owner == &System::id() && info.data_is_empty() } + +/// This is safe because it shortens lifetimes 'info: 'o and 'a: 'o to that of 'o +pub fn account_info_ref_lifetime_shortener<'info: 'a + 'o, 'a: 'o, 'o>( + ai: &'a AccountInfo<'info>, +) -> &'o AccountInfo<'o> { + unsafe { core::mem::transmute(ai) } +} \ No newline at end of file diff --git a/programs/locker/src/util/token2022.rs b/programs/locker/src/util/token2022.rs index d99d486..744a395 100644 --- a/programs/locker/src/util/token2022.rs +++ b/programs/locker/src/util/token2022.rs @@ -15,7 +15,7 @@ use anchor_spl::token_interface::{ TokenInterface, }; -use crate::{LockerError, VestingEscrow}; +use crate::{LockerError, VestingEscrow, VestingEscrowV3}; const ONE_IN_BASIS_POINTS: u128 = MAX_FEE_BASIS_POINTS as u128; @@ -151,6 +151,75 @@ pub fn transfer_to_user_v2<'c: 'info, 'info>( Ok(()) } +pub fn transfer_to_user_v3<'c: 'info, 'info>( + escrow: &AccountLoader<'info, VestingEscrowV3>, + token_mint: &InterfaceAccount<'info, Mint>, + escrow_token: &AccountInfo<'info>, + recipient_account: &InterfaceAccount<'info, TokenAccount>, + token_program: &Interface<'info, TokenInterface>, + memo_transfer_context: Option>, + amount: u64, + transfer_hook_accounts: Option<&'c [AccountInfo<'info>]>, +) -> Result<()> { + let escrow_state = escrow.load()?; + let escrow_seeds = escrow_seeds_v3!(escrow_state); + + if let Some(memo_ctx) = memo_transfer_context { + if is_transfer_memo_required(&recipient_account)? { + memo::build_memo( + CpiContext::new(memo_ctx.memo_program.to_account_info(), BuildMemo {}), + memo_ctx.memo, + )?; + } + } + + let mut instruction = spl_token_2022::instruction::transfer_checked( + token_program.key, + &escrow_token.key(), + &token_mint.key(), // mint + &recipient_account.key(), // to + &escrow.key(), // authority + &[], + amount, + token_mint.decimals, + )?; + + let mut account_infos = vec![ + escrow_token.to_account_info(), + token_mint.to_account_info(), + recipient_account.to_account_info(), + escrow.to_account_info(), + ]; + + // TransferHook extension + if let Some(hook_program_id) = get_transfer_hook_program_id(token_mint)? { + let Some(transfer_hook_accounts) = transfer_hook_accounts else { + return Err(LockerError::NoTransferHookProgram.into()); + }; + + spl_transfer_hook_interface::onchain::add_extra_accounts_for_execute_cpi( + &mut instruction, + &mut account_infos, + &hook_program_id, + escrow_token.to_account_info(), + token_mint.to_account_info(), + recipient_account.to_account_info(), + escrow.to_account_info(), + amount, + transfer_hook_accounts, + )?; + } else { + require!( + transfer_hook_accounts.is_none(), + LockerError::NoTransferHookProgram + ); + } + + solana_program::program::invoke_signed(&instruction, &account_infos, &[&escrow_seeds[..]])?; + + Ok(()) +} + pub fn validate_mint(token_mint: &InterfaceAccount) -> Result<()> { let token_mint_info = token_mint.to_account_info(); diff --git a/tests/common/index.ts b/tests/common/index.ts index 7ddba39..200a06d 100644 --- a/tests/common/index.ts +++ b/tests/common/index.ts @@ -31,6 +31,27 @@ export async function createAndFundWallet( }; } +export async function createAndFundBatchWallet( + connection: web3.Connection, + batchSize = 5 +) { + const batchWallet: any = []; + while (batchWallet.length <= batchSize) { + const item = await createAndFundWallet(connection); + batchWallet.push(item); + } + return batchWallet; +} + +export async function getTokenBalance( + connection: web3.Connection, + tokenAccount: web3.PublicKey +): Promise { + return Number( + (await connection.getTokenAccountBalance(tokenAccount)).value.amount + ); +} + export const encodeU32 = (num: number): Buffer => { const buf = Buffer.alloc(4); buf.writeUInt32LE(num); diff --git a/tests/locker_utils/index.ts b/tests/locker_utils/index.ts index 17b92f7..54e5334 100644 --- a/tests/locker_utils/index.ts +++ b/tests/locker_utils/index.ts @@ -18,14 +18,14 @@ import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, } from "@solana/spl-token"; -import { expect } from "chai"; +import { assert, expect } from "chai"; import { TokenExtensionUtil } from "./token_2022/token-extensions"; import { RemainingAccountsBuilder, RemainingAccountsType, } from "./token_2022/remaining-accounts"; import { AccountMeta, ComputeBudgetProgram } from "@solana/web3.js"; -import { getCurrentEpoch } from "../common"; +import { getCurrentEpoch, getTokenBalance } from "../common"; export const LOCKER_PROGRAM_ID = new web3.PublicKey( "2r5VekMNiWPzi1pWwvJczrdPaZnJG59u91unSrTunwJg" @@ -54,6 +54,27 @@ export function deriveEscrow(base: web3.PublicKey, programId: web3.PublicKey) { ); } +export function deriveEscrowV3( + base: web3.PublicKey, + programId: web3.PublicKey +) { + return web3.PublicKey.findProgramAddressSync( + [Buffer.from("escrow_v3"), base.toBuffer()], + programId + ); +} + +export function deriveClaimStatus( + recipient: web3.PublicKey, + escrow: web3.PublicKey, + programId: web3.PublicKey +) { + return web3.PublicKey.findProgramAddressSync( + [Buffer.from("claim_status"), recipient.toBuffer(), escrow.toBuffer()], + programId + ); +} + export function deriveEscrowMetadata( escrow: web3.PublicKey, programId: web3.PublicKey @@ -220,7 +241,7 @@ export interface CreateEscrowMetadataParams { name: string; description: string; creatorEmail: string; - recipientEmail: string; + recipientEndpoint: string; } export async function createEscrowMetadata(params: CreateEscrowMetadataParams) { @@ -230,7 +251,7 @@ export async function createEscrowMetadata(params: CreateEscrowMetadataParams) { name, description, creatorEmail, - recipientEmail, + recipientEndpoint, creator, } = params; const program = createLockerProgram(new Wallet(creator)); @@ -240,7 +261,7 @@ export async function createEscrowMetadata(params: CreateEscrowMetadataParams) { name, description, creatorEmail, - recipientEmail, + recipientEndpoint, }) .accounts({ escrow, @@ -263,8 +284,8 @@ export async function createEscrowMetadata(params: CreateEscrowMetadataParams) { expect(escrowMetadataState.creatorEmail.toString()).eq( creatorEmail.toString() ); - expect(escrowMetadataState.recipientEmail.toString()).eq( - recipientEmail.toString() + expect(escrowMetadataState.recipientEndpoint.toString()).eq( + recipientEndpoint.toString() ); } } @@ -274,18 +295,18 @@ export interface UpdateRecipientParams { signer: web3.Keypair; escrow: web3.PublicKey; newRecipient: web3.PublicKey; - newRecipientEmail: null | string; + newrecipientEndpoint: null | string; } export async function updateRecipient(params: UpdateRecipientParams) { - let { isAssertion, escrow, signer, newRecipient, newRecipientEmail } = params; + let { isAssertion, escrow, signer, newRecipient, newrecipientEndpoint } = params; const program = createLockerProgram(new Wallet(signer)); let escrowMetadata = null; - if (newRecipientEmail != null) { + if (newrecipientEndpoint != null) { [escrowMetadata] = deriveEscrowMetadata(escrow, program.programId); } await program.methods - .updateVestingEscrowRecipient(newRecipient, newRecipientEmail) + .updateVestingEscrowRecipient(newRecipient, newrecipientEndpoint) .accounts({ escrow, escrowMetadata, @@ -298,12 +319,12 @@ export async function updateRecipient(params: UpdateRecipientParams) { if (isAssertion) { const escrowState = await program.account.vestingEscrow.fetch(escrow); expect(escrowState.recipient.toString()).eq(newRecipient.toString()); - if (newRecipientEmail != null) { + if (newrecipientEndpoint != null) { [escrowMetadata] = deriveEscrowMetadata(escrow, program.programId); const escrowMetadataState = await program.account.vestingEscrowMetadata.fetch(escrowMetadata); - expect(escrowMetadataState.recipientEmail.toString()).eq( - newRecipientEmail.toString() + expect(escrowMetadataState.recipientEndpoint.toString()).eq( + newrecipientEndpoint.toString() ); } } @@ -499,6 +520,222 @@ export async function claimTokenV2(params: ClaimTokenParamsV2) { // console.log(" claim token signature", tx); } +// V3 instructions +export interface CreateVestingPlanV3Params { + isAssertion: boolean; + tokenMint: web3.PublicKey; + ownerKeypair: web3.Keypair; + totalDepositAmount: BN; + cancelMode: number; + root: number[]; + tokenProgram: web3.PublicKey; +} + +export async function createVestingPlanV3(params: CreateVestingPlanV3Params) { + let { + isAssertion, + tokenMint, + ownerKeypair, + totalDepositAmount, + cancelMode, + root, + tokenProgram, + } = params; + const program = createLockerProgram(new Wallet(ownerKeypair)); + + const baseKP = web3.Keypair.generate(); + + let [escrow] = deriveEscrowV3(baseKP.publicKey, program.programId); + + const senderToken = getAssociatedTokenAddressSync( + tokenMint, + ownerKeypair.publicKey, + false, + tokenProgram, + ASSOCIATED_TOKEN_PROGRAM_ID + ); + + const escrowToken = getAssociatedTokenAddressSync( + tokenMint, + escrow, + true, + tokenProgram, + ASSOCIATED_TOKEN_PROGRAM_ID + ); + + let remainingAccountsInfo = null; + let remainingAccounts: AccountMeta[] = []; + if (tokenProgram == TOKEN_2022_PROGRAM_ID) { + let inputTransferHookAccounts = + await TokenExtensionUtil.getExtraAccountMetasForTransferHook( + program.provider.connection, + tokenMint, + senderToken, + escrowToken, + ownerKeypair.publicKey, + TOKEN_2022_PROGRAM_ID + ); + + [remainingAccountsInfo, remainingAccounts] = new RemainingAccountsBuilder() + .addSlice( + RemainingAccountsType.TransferHookEscrow, + inputTransferHookAccounts + ) + .build(); + } + + await program.methods + .createVestingEscrowV3( + { + totalDepositAmount, + root, + cancelMode, + }, + remainingAccountsInfo + ) + .accounts({ + base: baseKP.publicKey, + escrow, + senderToken, + escrowToken, + tokenMint, + sender: ownerKeypair.publicKey, + tokenProgram, + systemProgram: web3.SystemProgram.programId, + }) + .remainingAccounts(remainingAccounts ? remainingAccounts : []) + .preInstructions([ + createAssociatedTokenAccountInstruction( + ownerKeypair.publicKey, + escrowToken, + escrow, + tokenMint, + tokenProgram, + ASSOCIATED_TOKEN_PROGRAM_ID + ), + ]) + .signers([baseKP, ownerKeypair]) + .rpc(); + + if (isAssertion) { + const escrowState = await program.account.vestingEscrowV3.fetch(escrow); + expect(escrowState.tokenMint.toString()).eq(tokenMint.toString()); + expect(escrowState.creator.toString()).eq( + ownerKeypair.publicKey.toString() + ); + expect(escrowState.base.toString()).eq(baseKP.publicKey.toString()); + expect(escrowState.tokenProgramFlag).eq( + tokenProgram == TOKEN_PROGRAM_ID ? 0 : 1 + ); + expect(escrowState.root).to.deep.equal(root); + expect(escrowState.totalDepositAmount.toNumber()).eq( + totalDepositAmount.toNumber() + ); + } + + return escrow; +} + +export interface ClaimV3Params { + tokenMint: web3.PublicKey; + isAssertion: boolean; + vestingStartTime: BN; + cliffTime: BN; + frequency: BN; + cliffUnlockAmount: BN; + amountPerPeriod: BN; + numberOfPeriod: BN; + recipient: web3.Keypair; + tokenProgram?: web3.PublicKey; + escrow: web3.PublicKey; + maxAmount: BN; + recipientToken: web3.PublicKey; + proof: any; +} + +export async function claimTokenV3(params: ClaimV3Params) { + let { + isAssertion, + frequency, + escrow, + recipient, + maxAmount, + recipientToken, + vestingStartTime, + cliffTime, + amountPerPeriod, + proof, + numberOfPeriod, + cliffUnlockAmount, + } = params; + const program = createLockerProgram(new Wallet(recipient)); + const escrowState = await program.account.vestingEscrowV3.fetch(escrow); + let [claimStatus] = deriveClaimStatus( + recipient.publicKey, + escrow, + program.programId + ); + const tokenProgram = + escrowState.tokenProgramFlag == ESCROW_USE_SPL_TOKEN + ? TOKEN_PROGRAM_ID + : TOKEN_2022_PROGRAM_ID; + + const escrowToken = getAssociatedTokenAddressSync( + escrowState.tokenMint, + escrow, + true, + tokenProgram, + ASSOCIATED_TOKEN_PROGRAM_ID + ); + + let remainingAccountsInfo = null; + let remainingAccounts: AccountMeta[] = []; + if (tokenProgram == TOKEN_2022_PROGRAM_ID) { + let claimTransferHookAccounts = + await TokenExtensionUtil.getExtraAccountMetasForTransferHook( + program.provider.connection, + escrowState.tokenMint, + escrowToken, + recipientToken, + escrow, + TOKEN_2022_PROGRAM_ID + ); + + [remainingAccountsInfo, remainingAccounts] = new RemainingAccountsBuilder() + .addSlice( + RemainingAccountsType.TransferHookEscrow, + claimTransferHookAccounts + ) + .build(); + } + const claimParams = { + vestingStartTime, + cliffTime, + frequency, + amountPerPeriod, + numberOfPeriod, + maxAmount, + cliffUnlockAmount, + proof, + }; + await program.methods + .claimV3(claimParams, remainingAccountsInfo) + .accounts({ + tokenProgram, + tokenMint: escrowState.tokenMint, + memoProgram: MEMO_PROGRAM, + claimStatus, + escrow, + escrowToken, + recipient: recipient.publicKey, + payer: recipient.publicKey, + recipientToken, + }) + .remainingAccounts(remainingAccounts ? remainingAccounts : []) + .signers([recipient]) + .rpc(); +} + export interface CancelVestingPlanParams { isAssertion: boolean; escrow: web3.PublicKey; @@ -601,12 +838,12 @@ export async function cancelVestingPlan( const epoch = BigInt(await getCurrentEpoch(program.provider.connection)); creator_fee = feeConfig ? Number( - calculateEpochFee( - feeConfig, - epoch, - BigInt(total_amount - claimable_amount) + calculateEpochFee( + feeConfig, + epoch, + BigInt(total_amount - claimable_amount) + ) ) - ) : 0; claimer_fee = feeConfig ? Number(calculateEpochFee(feeConfig, epoch, BigInt(claimable_amount))) @@ -627,9 +864,9 @@ export async function cancelVestingPlan( ).value.amount; expect( parseInt(creator_token_balance_before) + - total_amount - - claimable_amount - - creator_fee + total_amount - + claimable_amount - + creator_fee ).eq(parseInt(creator_token_balance)); const recipient_token_balance = ( @@ -641,6 +878,110 @@ export async function cancelVestingPlan( } } +export interface CancelVestingPlanV3Params { + isAssertion: boolean; + escrow: web3.PublicKey; + rentReceiver: web3.PublicKey; + creatorToken: web3.PublicKey; + signer: web3.Keypair; +} + +export async function cancelVestingPlanV3( + params: CancelVestingPlanV3Params, + claimable_amount: number, + total_amount: number +) { + let { isAssertion, escrow, rentReceiver, creatorToken, signer } = params; + const program = createLockerProgram(new Wallet(signer)); + const escrowState = await program.account.vestingEscrowV3.fetch(escrow); + const tokenProgram = + escrowState.tokenProgramFlag == ESCROW_USE_SPL_TOKEN + ? TOKEN_PROGRAM_ID + : TOKEN_2022_PROGRAM_ID; + + const escrowToken = getAssociatedTokenAddressSync( + escrowState.tokenMint, + escrow, + true, + tokenProgram, + ASSOCIATED_TOKEN_PROGRAM_ID + ); + + const creator_token_balance_before = ( + await program.provider.connection.getTokenAccountBalance(creatorToken) + ).value.amount; + + let remainingAccountsInfo = null; + let remainingAccounts: AccountMeta[] = []; + if (tokenProgram == TOKEN_2022_PROGRAM_ID) { + let cancelTransferHookAccounts = + await TokenExtensionUtil.getExtraAccountMetasForTransferHook( + program.provider.connection, + escrowState.tokenMint, + escrowToken, + creatorToken, + escrow, + tokenProgram + ); + + [remainingAccountsInfo, remainingAccounts] = new RemainingAccountsBuilder() + .addSlice( + RemainingAccountsType.TransferHookEscrow, + cancelTransferHookAccounts + ) + .build(); + } + await program.methods + .cancelVestingEscrowV3(remainingAccountsInfo) + .accounts({ + escrow, + tokenMint: escrowState.tokenMint, + escrowToken, + rentReceiver, + creatorToken: creatorToken, + signer: signer.publicKey, + tokenProgram, + memoProgram: MEMO_PROGRAM, + }) + .preInstructions([ + ComputeBudgetProgram.setComputeUnitLimit({ + units: 400_000, + }), + ]) + .remainingAccounts(remainingAccounts ? remainingAccounts : []) + .signers([signer]) + .rpc(); + let creator_fee = 0; + let claimer_fee = 0; + if (tokenProgram == TOKEN_2022_PROGRAM_ID) { + const feeConfig = getTransferFeeConfig( + await getMint( + program.provider.connection, + escrowState.tokenMint, + undefined, + TOKEN_2022_PROGRAM_ID + ) + ); + const epoch = BigInt(await getCurrentEpoch(program.provider.connection)); + creator_fee = feeConfig + ? Number(calculateEpochFee(feeConfig, epoch, BigInt(total_amount))) + : 0; + claimer_fee = feeConfig + ? Number(calculateEpochFee(feeConfig, epoch, BigInt(claimable_amount))) + : 0; + } + + if (isAssertion) { + const escrowState = await program.account.vestingEscrowV3.fetch(escrow); + expect(escrowState.cancelledAt.toNumber()).greaterThan(0); + + const escrowTokenAccount = await program.provider.connection.getAccountInfo( + escrowToken + ); + expect(escrowTokenAccount).eq(null); + } +} + export interface CloseVestingEscrowParams { isAssertion: boolean; creator: web3.Keypair; @@ -653,8 +994,10 @@ export async function closeVestingEscrow(params: CloseVestingEscrowParams) { let [escrowMetadata] = deriveEscrowMetadata(escrow, program.programId); let escrowState = await program.account.vestingEscrow.fetch(escrow); - - let tokenProgram = escrowState.tokenProgramFlag == 0 ? TOKEN_PROGRAM_ID : TOKEN_2022_PROGRAM_ID; + let tokenProgram = + escrowState.tokenProgramFlag == 0 + ? TOKEN_PROGRAM_ID + : TOKEN_2022_PROGRAM_ID; const escrowToken = getAssociatedTokenAddressSync( escrowState.tokenMint, escrow, @@ -703,15 +1046,65 @@ export async function closeVestingEscrow(params: CloseVestingEscrowParams) { tokenMint: escrowState.tokenMint, tokenProgram, creator: creator.publicKey, - }).signers([creator]).remainingAccounts(remainingAccounts) + }) + .signers([creator]) + .remainingAccounts(remainingAccounts) .rpc(); if (isAssertion) { - let escrowStateAfter = await program.account.vestingEscrow.fetchNullable(escrow); + let escrowStateAfter = await program.account.vestingEscrow.fetchNullable( + escrow + ); expect(escrowStateAfter).eq(null); - let escrowMetadataState = await program.account.vestingEscrowMetadata.fetchNullable(escrow); + let escrowMetadataState = + await program.account.vestingEscrowMetadata.fetchNullable(escrow); expect(escrowMetadataState).eq(null); - let escrowTokenState = await program.provider.connection.getAccountInfo(escrowToken); + let escrowTokenState = await program.provider.connection.getAccountInfo( + escrowToken + ); expect(escrowTokenState).eq(null); } } + +export interface CloseClaimStatusParams { + isAssertion: boolean; + recipient: web3.Keypair; + escrow: web3.PublicKey; + rentReceiver: web3.PublicKey; +} + +export async function closeClaimStatus(params: CloseClaimStatusParams) { + let { isAssertion, escrow, recipient, rentReceiver } = params; + const program = createLockerProgram(new Wallet(recipient)); + let [claimStatus] = deriveClaimStatus( + recipient.publicKey, + escrow, + program.programId + ); + const rentBalance = await program.provider.connection.getBalance(claimStatus); + const preBalance = await program.provider.connection.getBalance(rentReceiver); + try { + await program.methods + .closeClaimStatus() + .accounts({ + escrow, + claimStatus, + rentReceiver: rentReceiver, + recipient: recipient.publicKey, + }) + .signers([recipient]) + .rpc(); + } catch (e) { + console.log(e); + } + if (isAssertion) { + let claimStatusState = await program.account.claimStatus.fetchNullable( + claimStatus + ); + expect(claimStatusState).eq(null); + const postBalance = await program.provider.connection.getBalance( + rentReceiver + ); + expect(preBalance + rentBalance).eq(postBalance); + } +} diff --git a/tests/locker_utils/merkle_tree/EscrowRecipientTree.ts b/tests/locker_utils/merkle_tree/EscrowRecipientTree.ts new file mode 100644 index 0000000..7c6bf5a --- /dev/null +++ b/tests/locker_utils/merkle_tree/EscrowRecipientTree.ts @@ -0,0 +1,68 @@ +import { BN, web3 } from "@coral-xyz/anchor"; +import { sha256 } from "js-sha256"; +import { MerkleTree } from "./MerkleTree"; + +export type NodeType = { + account: web3.PublicKey; + cliffUnlockAmount: BN; + amountPerPeriod: BN; + numberOfPeriod: BN; + cliffTime: BN; + frequency: BN; + vestingStartTime: BN; +}; +export class EscrowRecipientTree { + private readonly _tree: MerkleTree; + constructor(nodeData: NodeType[]) { + this._validateNode(nodeData); + this._tree = new MerkleTree( + nodeData.map((node: NodeType) => { + return EscrowRecipientTree.toNode(node); + }) + ); + } + + private _validateNode(nodeData: NodeType[]) { + nodeData.forEach((item: NodeType) => { + if (item.vestingStartTime.gte(item.cliffTime)) { + throw Error("cliff time must greater or equal vesting start time"); + } + if (!item.frequency.gtn(0)) { + throw Error("frequency must geater than zero"); + } + }); + } + static toNode(node: NodeType): Buffer { + const { + account, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + cliffTime, + frequency, + vestingStartTime, + } = node; + const buf = Buffer.concat([ + account.toBuffer(), + new BN(cliffUnlockAmount).toArrayLike(Buffer, "le", 8), + new BN(amountPerPeriod).toArrayLike(Buffer, "le", 8), + new BN(numberOfPeriod).toArrayLike(Buffer, "le", 8), + new BN(cliffTime).toArrayLike(Buffer, "le", 8), + new BN(frequency).toArrayLike(Buffer, "le", 8), + new BN(vestingStartTime).toArrayLike(Buffer, "le", 8), + ]); + + const hashedBuff = Buffer.from(sha256(buf), "hex"); + const bufWithPrefix = Buffer.concat([Buffer.from([0]), hashedBuff]); + + return Buffer.from(sha256(bufWithPrefix), "hex"); + } + + getRoot(): Buffer { + return this._tree.getRoot(); + } + + getProof(node: NodeType): Buffer[] { + return this._tree.getProof(EscrowRecipientTree.toNode(node)); + } +} diff --git a/tests/locker_utils/merkle_tree/MerkleTree.ts b/tests/locker_utils/merkle_tree/MerkleTree.ts new file mode 100644 index 0000000..06fc00a --- /dev/null +++ b/tests/locker_utils/merkle_tree/MerkleTree.ts @@ -0,0 +1,142 @@ +import { sha256 } from "js-sha256"; +import invariant from "tiny-invariant"; + +function getPairElement(idx: number, layer: Buffer[]): Buffer | null { + const pairIdx = idx % 2 === 0 ? idx + 1 : idx - 1; + + if (pairIdx < layer.length) { + const pairEl = layer[pairIdx]; + invariant(pairEl, "pairEl"); + return pairEl; + } else { + return null; + } +} + +function bufDedup(elements: Buffer[]): Buffer[] { + return elements.filter((el, idx) => { + return idx === 0 || !elements[idx - 1]?.equals(el); + }); +} + +function bufArrToHexArr(arr: Buffer[]): string[] { + if (arr.some((el) => !Buffer.isBuffer(el))) { + throw new Error("Array is not an array of buffers"); + } + + return arr.map((el) => "0x" + el.toString("hex")); +} + +function sortAndConcat(...args: Buffer[]): Buffer { + return Buffer.concat([ + Buffer.from([1]), + Buffer.concat([...args].sort(Buffer.compare.bind(null))), + ]); +} + +export class MerkleTree { + private readonly _elements: Buffer[]; + private readonly _bufferElementPositionIndex: { + [hexElement: string]: number; + }; + private readonly _layers: Buffer[][]; + + constructor(elements: Buffer[]) { + this._elements = [...elements]; + // Sort elements + this._elements.sort(Buffer.compare.bind(null)); + // Deduplicate elements + this._elements = bufDedup(this._elements); + + this._bufferElementPositionIndex = this._elements.reduce<{ + [hexElement: string]: number; + }>((memo, el, index) => { + memo[el.toString("hex")] = index; + return memo; + }, {}); + + // Create layers + this._layers = this.getLayers(this._elements); + } + + getLayers(elements: Buffer[]): Buffer[][] { + if (elements.length === 0) { + throw new Error("empty tree"); + } + + const layers = []; + layers.push(elements); + + // Get next layer until we reach the root + while ((layers[layers.length - 1]?.length ?? 0) > 1) { + const nextLayerIndex: Buffer[] | undefined = layers[layers.length - 1]; + invariant(nextLayerIndex, "nextLayerIndex"); + layers.push(this.getNextLayer(nextLayerIndex)); + } + + return layers; + } + + getNextLayer(elements: Buffer[]): Buffer[] { + return elements.reduce((layer, el, idx, arr) => { + if (idx % 2 === 0) { + // Hash the current element with its pair element + const pairEl = arr[idx + 1]; + layer.push(MerkleTree.combinedHash(el, pairEl)); + } + + return layer; + }, []); + } + + static combinedHash(first: Buffer, second: Buffer | undefined): Buffer { + if (!first) { + invariant(second, "second element of pair must exist"); + return second; + } + if (!second) { + invariant(first, "first element of pair must exist"); + return first; + } + + return Buffer.from(sha256(sortAndConcat(first, second)), "hex"); + } + + getRoot(): Buffer { + const root = this._layers[this._layers.length - 1]?.[0]; + invariant(root, "root"); + return root; + } + + getHexRoot(): string { + return this.getRoot().toString("hex"); + } + + getProof(el: Buffer): Buffer[] { + const initialIdx = this._bufferElementPositionIndex[el.toString("hex")]; + + // disable to testcase fake proof + // if (typeof initialIdx !== "number") { + // throw new Error("Element does not exist in Merkle tree"); + // } + + let idx = initialIdx; + return this._layers.reduce((proof, layer) => { + const pairElement = getPairElement(idx, layer); + + if (pairElement) { + proof.push(pairElement); + } + + idx = Math.floor(idx / 2); + + return proof; + }, []); + } + + getHexProof(el: Buffer): string[] { + const proof = this.getProof(el); + + return bufArrToHexArr(proof); + } +} diff --git a/tests/locker_utils/merkle_tree/index.ts b/tests/locker_utils/merkle_tree/index.ts new file mode 100644 index 0000000..61866c6 --- /dev/null +++ b/tests/locker_utils/merkle_tree/index.ts @@ -0,0 +1,11 @@ +import { EscrowRecipientTree, NodeType } from "./EscrowRecipientTree"; + +export function generateMerkleTreeRoot(dataNode: NodeType[]): number[] { + const escrowRecipientTree = new EscrowRecipientTree(dataNode); + return Array.from(escrowRecipientTree.getRoot()); +} + +export function getMerkleTreeProof(dataNode: NodeType[], userNode: NodeType) { + const escrowRecipientTree = new EscrowRecipientTree(dataNode); + return escrowRecipientTree.getProof(userNode); +} diff --git a/tests/test_close_vesting_escrow.ts b/tests/test_close_vesting_escrow.ts index d985441..fb47741 100644 --- a/tests/test_close_vesting_escrow.ts +++ b/tests/test_close_vesting_escrow.ts @@ -142,7 +142,7 @@ describe("Close vesting escrow", () => { name: "Jupiter lock", description: "This is jupiter lock", creatorEmail: "andrew@raccoons.dev", - recipientEmail: "max@raccoons.dev", + recipientEndpoint: "max@raccoons.dev", creator: UserKP, isAssertion: true, }); @@ -295,7 +295,7 @@ describe("Close vesting escrow", () => { name: "Jupiter lock", description: "This is jupiter lock", creatorEmail: "andrew@raccoons.dev", - recipientEmail: "max@raccoons.dev", + recipientEndpoint: "max@raccoons.dev", creator: UserKP, isAssertion: true, }); diff --git a/tests/test_escrow_metadata.ts b/tests/test_escrow_metadata.ts index b380e18..f205624 100644 --- a/tests/test_escrow_metadata.ts +++ b/tests/test_escrow_metadata.ts @@ -124,7 +124,7 @@ describe("Escrow metadata", () => { name: "Jupiter lock", description: "This is jupiter lock", creatorEmail: "andrew@raccoons.dev", - recipientEmail: "max@raccoons.dev", + recipientEndpoint: "max@raccoons.dev", creator: UserKP, isAssertion: true, }); diff --git a/tests/test_update_recipient.ts b/tests/test_update_recipient.ts index 9780605..f0a34a9 100644 --- a/tests/test_update_recipient.ts +++ b/tests/test_update_recipient.ts @@ -132,7 +132,7 @@ describe("Update recipient", () => { newRecipient: newRecipient.publicKey, isAssertion: true, signer: UserKP, - newRecipientEmail: null, + newrecipientEndpoint: null, }); }, "Not permit to do this action", @@ -146,7 +146,7 @@ describe("Update recipient", () => { newRecipient: newRecipient.publicKey, isAssertion: true, signer: RecipientKP, - newRecipientEmail: null, + newrecipientEndpoint: null, }); }, "Not permit to do this action", @@ -184,7 +184,7 @@ describe("Update recipient", () => { newRecipient: newRecipient.publicKey, isAssertion: true, signer: RecipientKP, - newRecipientEmail: null, + newrecipientEndpoint: null, }); }, "Not permit to do this action", @@ -196,7 +196,7 @@ describe("Update recipient", () => { newRecipient: newRecipient.publicKey, isAssertion: true, signer: UserKP, - newRecipientEmail: null, + newrecipientEndpoint: null, }); }); @@ -230,7 +230,7 @@ describe("Update recipient", () => { newRecipient: newRecipient.publicKey, isAssertion: true, signer: UserKP, - newRecipientEmail: null, + newrecipientEndpoint: null, }); }, "Not permit to do this action", @@ -242,7 +242,7 @@ describe("Update recipient", () => { newRecipient: newRecipient.publicKey, isAssertion: true, signer: RecipientKP, - newRecipientEmail: null, + newrecipientEndpoint: null, }); }); @@ -273,7 +273,7 @@ describe("Update recipient", () => { newRecipient: RecipientKP.publicKey, isAssertion: true, signer: UserKP, - newRecipientEmail: null, + newrecipientEndpoint: null, }); await updateRecipient({ @@ -281,11 +281,11 @@ describe("Update recipient", () => { newRecipient: RecipientKP.publicKey, isAssertion: true, signer: RecipientKP, - newRecipientEmail: null, + newrecipientEndpoint: null, }); }); - it("Update both recipient and recipient email", async () => { + it("Update both recipient and recipient endpoint", async () => { console.log("Create vesting plan"); const program = createLockerProgram(new anchor.Wallet(UserKP)); let currentBlockTime = await getCurrentBlockTime( @@ -313,12 +313,12 @@ describe("Update recipient", () => { name: "Jupiter lock", description: "This is jupiter lock", creatorEmail: "andrew@raccoons.dev", - recipientEmail: "max@raccoons.dev", + recipientEndpoint: "max@raccoons.dev", creator: UserKP, isAssertion: true, }); - it("Update both recipient and recipient email", async () => { + it("Update both recipient and recipient endpoint", async () => { console.log("Create vesting plan"); const program = createLockerProgram(new anchor.Wallet(UserKP)); let currentBlockTime = await getCurrentBlockTime( @@ -346,7 +346,7 @@ describe("Update recipient", () => { name: "Jupiter lock", description: "This is jupiter lock", creatorEmail: "andrew@raccoons.dev", - recipientEmail: "max@raccoons.dev", + recipientEndpoint: "max@raccoons.dev", creator: UserKP, isAssertion: true, }); @@ -357,7 +357,7 @@ describe("Update recipient", () => { newRecipient: RecipientKP.publicKey, isAssertion: true, signer: UserKP, - newRecipientEmail: "maximillian@raccoons.dev", + newrecipientEndpoint: "maximillian@raccoons.dev", }); console.log("Update recipient with smaller email size"); @@ -366,7 +366,7 @@ describe("Update recipient", () => { newRecipient: RecipientKP.publicKey, isAssertion: true, signer: UserKP, - newRecipientEmail: "max@raccoons.dev", + newrecipientEndpoint: "max@raccoons.dev", }); }); }); diff --git a/tests/v2/test_update_recipient.ts b/tests/v2/test_update_recipient.ts index 0e297b6..ecce6d7 100644 --- a/tests/v2/test_update_recipient.ts +++ b/tests/v2/test_update_recipient.ts @@ -169,7 +169,7 @@ describe("[V2] Update recipient", () => { newRecipient: newRecipient.publicKey, isAssertion: true, signer: UserKP, - newRecipientEmail: null, + newrecipientEndpoint: null, }); }, "Not permit to do this action", @@ -183,7 +183,7 @@ describe("[V2] Update recipient", () => { newRecipient: newRecipient.publicKey, isAssertion: true, signer: RecipientKP, - newRecipientEmail: null, + newrecipientEndpoint: null, }); }, "Not permit to do this action", @@ -223,7 +223,7 @@ describe("[V2] Update recipient", () => { newRecipient: newRecipient.publicKey, isAssertion: true, signer: RecipientKP, - newRecipientEmail: null, + newrecipientEndpoint: null, }); }, "Not permit to do this action", @@ -235,7 +235,7 @@ describe("[V2] Update recipient", () => { newRecipient: newRecipient.publicKey, isAssertion: true, signer: UserKP, - newRecipientEmail: null, + newrecipientEndpoint: null, }); }); @@ -271,7 +271,7 @@ describe("[V2] Update recipient", () => { newRecipient: newRecipient.publicKey, isAssertion: true, signer: UserKP, - newRecipientEmail: null, + newrecipientEndpoint: null, }); }, "Not permit to do this action", @@ -283,7 +283,7 @@ describe("[V2] Update recipient", () => { newRecipient: newRecipient.publicKey, isAssertion: true, signer: RecipientKP, - newRecipientEmail: null, + newrecipientEndpoint: null, }); }); @@ -316,7 +316,7 @@ describe("[V2] Update recipient", () => { newRecipient: RecipientKP.publicKey, isAssertion: true, signer: UserKP, - newRecipientEmail: null, + newrecipientEndpoint: null, }); await updateRecipient({ @@ -324,11 +324,11 @@ describe("[V2] Update recipient", () => { newRecipient: RecipientKP.publicKey, isAssertion: true, signer: RecipientKP, - newRecipientEmail: null, + newrecipientEndpoint: null, }); }); - it("Update both recipient and recipient email", async () => { + it("Update both recipient and recipient endpoint", async () => { console.log("Create vesting plan"); const program = createLockerProgram(new anchor.Wallet(UserKP)); let currentBlockTime = await getCurrentBlockTime( @@ -358,7 +358,7 @@ describe("[V2] Update recipient", () => { name: "Jupiter lock", description: "This is jupiter lock", creatorEmail: "andrew@raccoons.dev", - recipientEmail: "max@raccoons.dev", + recipientEndpoint: "max@raccoons.dev", creator: UserKP, isAssertion: true, }); @@ -369,7 +369,7 @@ describe("[V2] Update recipient", () => { newRecipient: RecipientKP.publicKey, isAssertion: true, signer: UserKP, - newRecipientEmail: "maximillian@raccoons.dev", + newrecipientEndpoint: "maximillian@raccoons.dev", }); console.log("Update recipient with smaller email size"); @@ -378,7 +378,7 @@ describe("[V2] Update recipient", () => { newRecipient: RecipientKP.publicKey, isAssertion: true, signer: UserKP, - newRecipientEmail: "max@raccoons.dev", + newrecipientEndpoint: "max@raccoons.dev", }); }); }); diff --git a/tests/v3/test_cancel.ts b/tests/v3/test_cancel.ts new file mode 100644 index 0000000..b3ea7ff --- /dev/null +++ b/tests/v3/test_cancel.ts @@ -0,0 +1,343 @@ +import * as anchor from "@coral-xyz/anchor"; +import { web3 } from "@coral-xyz/anchor"; +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + createAssociatedTokenAccountIdempotent, + ExtensionType, + getAssociatedTokenAddress, + TOKEN_2022_PROGRAM_ID, +} from "@solana/spl-token"; +import { BN } from "bn.js"; +import { + createAndFundBatchWallet, + createAndFundWallet, + getCurrentBlockTime, + invokeAndAssertError, + sleep, +} from "../common"; +import { + createLockerProgram, + createVestingPlanV3, + cancelVestingPlanV3, +} from "../locker_utils"; +import { ADMIN, createMintTransaction } from "../locker_utils/token_2022/mint"; +import { + generateMerkleTreeRoot, + getMerkleTreeProof, +} from "../locker_utils/merkle_tree"; + +const provider = anchor.AnchorProvider.env(); +provider.opts.commitment = "confirmed"; + +describe("[V3] Cancel escrow", () => { + let TOKEN: web3.PublicKey; + let UserKP: web3.Keypair; + let recipients: web3.Keypair[]; + let recipientAtas: web3.PublicKey[]; + let totalDepositAmount; + let vestingStartTime; + let cliffTime; + let frequency; + let cliffUnlockAmount; + let amountPerPeriod; + let numberOfPeriod; + let totalLockedAmount; + let leaves: any; + let UserToken: web3.PublicKey; + + let extensions: ExtensionType[]; + let root: any; + let proof: any; + + before(async () => { + { + await createAndFundWallet(provider.connection, ADMIN); + } + { + const result = await createAndFundWallet(provider.connection); + UserKP = result.keypair; + } + { + const result = await createAndFundBatchWallet(provider.connection); + recipients = result.map((item: any) => item.keypair); + } + + // Define the extensions to be used by the mint + extensions = [ExtensionType.TransferFeeConfig, ExtensionType.TransferHook]; + + TOKEN = await createMintTransaction( + provider, + UserKP, + extensions, + true, + false + ); + UserToken = await getAssociatedTokenAddress( + TOKEN, + UserKP.publicKey, + true, + TOKEN_2022_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID + ); + + recipientAtas = []; + for (const recipientor of recipients) { + const recipientorAta = await createAssociatedTokenAccountIdempotent( + provider.connection, + UserKP, + TOKEN, + recipientor.publicKey, + {}, + TOKEN_2022_PROGRAM_ID + ); + recipientAtas.push(recipientorAta); + } + + // create root & proof + // default value + vestingStartTime = new BN(0); + let currentBlockTime = await getCurrentBlockTime(provider.connection); + cliffTime = new BN(currentBlockTime).add(new BN(5)); + frequency = new BN(2); + cliffUnlockAmount = new BN(100_000); + amountPerPeriod = new BN(50_000); + numberOfPeriod = new BN(2); + totalLockedAmount = cliffUnlockAmount.add( + amountPerPeriod.mul(numberOfPeriod) + ); + leaves = recipients.map((item) => { + return { + account: item.publicKey, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + cliffTime, + frequency, + vestingStartTime + }; + }); + const user = { + account: recipients[0].publicKey, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + cliffTime, + frequency, + vestingStartTime + }; + totalDepositAmount = totalLockedAmount.muln(leaves.length); + root = generateMerkleTreeRoot(leaves); + proof = getMerkleTreeProof(leaves, user); + }); + + it("No one is able to cancel", async () => { + let escrow = await createVestingPlanV3({ + ownerKeypair: UserKP, + tokenMint: TOKEN, + isAssertion: true, + tokenProgram: TOKEN_2022_PROGRAM_ID, + totalDepositAmount, + cancelMode: 0, + root, + }); + + console.log("Cancel vesting plan"); + invokeAndAssertError( + async () => { + await cancelVestingPlanV3( + { + escrow, + isAssertion: true, + rentReceiver: UserKP.publicKey, + creatorToken: UserToken, + signer: UserKP, + }, + 0, + 200_000 + ); + }, + "Not permit to do this action", + true + ); + + invokeAndAssertError( + async () => { + await cancelVestingPlanV3( + { + escrow, + isAssertion: true, + rentReceiver: UserKP.publicKey, + creatorToken: UserToken, + signer: recipients[0], + }, + 0, + 200_000 + ); + }, + "Not permit to do this action", + true + ); + }); + + it("Creator is able to cancel", async () => { + let escrow = await createVestingPlanV3({ + ownerKeypair: UserKP, + tokenMint: TOKEN, + isAssertion: true, + tokenProgram: TOKEN_2022_PROGRAM_ID, + totalDepositAmount, + cancelMode: 1, + root, + }); + + console.log("Cancel Vesting Plan"); + invokeAndAssertError( + async () => { + await cancelVestingPlanV3( + { + escrow, + isAssertion: true, + rentReceiver: UserKP.publicKey, + creatorToken: UserToken, + signer: recipients[0], + }, + 0, + 200_000 + ); + }, + "Not permit to do this action", + true + ); + + await cancelVestingPlanV3( + { + escrow, + isAssertion: true, + rentReceiver: UserKP.publicKey, + creatorToken: UserToken, + signer: UserKP, + }, + 0, + 200_000 + ); + }); + + it("Creator cancel escrow with another destination fee account", async () => { + let escrow = await createVestingPlanV3({ + ownerKeypair: UserKP, + tokenMint: TOKEN, + isAssertion: true, + tokenProgram: TOKEN_2022_PROGRAM_ID, + totalDepositAmount, + cancelMode: 1, + root, + }); + + console.log("Cancel Vesting Plan"); + invokeAndAssertError( + async () => { + await cancelVestingPlanV3( + { + escrow, + isAssertion: true, + rentReceiver: UserKP.publicKey, + creatorToken: UserToken, + signer: recipients[0], + }, + 0, + 200_000 + ); + }, + "Not permit to do this action", + true + ); + + const newDestination = web3.Keypair.generate() + const desAta = await createAssociatedTokenAccountIdempotent( + provider.connection, + UserKP, + TOKEN, + newDestination.publicKey, + {}, + TOKEN_2022_PROGRAM_ID + ); + + await cancelVestingPlanV3( + { + escrow, + isAssertion: true, + rentReceiver: UserKP.publicKey, + creatorToken: desAta, + signer: UserKP, + }, + 0, + 200_000 + ); + }); + + it("Recipient is not able to cancel", async () => { + let escrow = await createVestingPlanV3({ + ownerKeypair: UserKP, + tokenMint: TOKEN, + isAssertion: true, + tokenProgram: TOKEN_2022_PROGRAM_ID, + totalDepositAmount, + cancelMode: 2, + root, + }); + + console.log("Cancel Vesting Plan"); + invokeAndAssertError( + async () => { + await cancelVestingPlanV3( + { + escrow, + isAssertion: true, + rentReceiver: UserKP.publicKey, + creatorToken: UserToken, + signer: recipients[0], + }, + 0, + 200_000 + ); + }, + "Not permit to do this action", + true + ); + }); + + it("Creator is able to cancel after cliff time", async () => { + let escrow = await createVestingPlanV3({ + ownerKeypair: UserKP, + tokenMint: TOKEN, + isAssertion: true, + tokenProgram: TOKEN_2022_PROGRAM_ID, + totalDepositAmount, + cancelMode: 1, + root, + }); + + while (true) { + const currentBlockTime = await getCurrentBlockTime(provider.connection); + if (currentBlockTime > cliffTime.toNumber()) { + break; + } else { + await sleep(1000); + console.log("Wait until startTime"); + } + } + + console.log("Cancel Vesting Plan"); + await cancelVestingPlanV3( + { + escrow, + isAssertion: true, + rentReceiver: UserKP.publicKey, + creatorToken: UserToken, + signer: UserKP, + }, + 0, + 200_000 + ); + }); +}); diff --git a/tests/v3/test_close_claim_status.ts b/tests/v3/test_close_claim_status.ts new file mode 100644 index 0000000..cadc96a --- /dev/null +++ b/tests/v3/test_close_claim_status.ts @@ -0,0 +1,407 @@ +import * as anchor from "@coral-xyz/anchor"; +import { web3 } from "@coral-xyz/anchor"; +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + createAssociatedTokenAccountIdempotent, + createInitializeMint2Instruction, + ExtensionType, + getAssociatedTokenAddress, + mintTo, + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; +import { BN } from "bn.js"; +import { createAndFundWallet, getCurrentBlockTime, sleep } from "../common"; +import { + cancelVestingPlanV3, + closeClaimStatus, +} from "../locker_utils"; +import { + sendAndConfirmTransaction, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import { createMintTransaction } from "../locker_utils/token_2022/mint"; +import { + generateMerkleTreeRoot, + getMerkleTreeProof, +} from "../locker_utils/merkle_tree"; +import { createAndFundBatchWallet } from "../common"; +import { claimTokenV3, createVestingPlanV3 } from "../locker_utils"; + +let provider = anchor.AnchorProvider.env(); +provider.opts.commitment = "confirmed"; + +describe("[V3] Close vesting escrow", () => { + // default value + let vestingStartTime = new BN(0); + let cliffTimeDurraion = 1; + let frequency = new BN(1); + let cliffUnlockAmount = new BN(100_000); + let amountPerPeriod = new BN(50_000); + let numberOfPeriod = new BN(1); + let totalLockedAmount = cliffUnlockAmount.add( + amountPerPeriod.mul(numberOfPeriod) + ); + describe("Close vesting escrow with spl-token", () => { + const tokenDecimal = 8; + let mintAuthority: web3.Keypair; + let mintKeypair: web3.Keypair; + let TOKEN: web3.PublicKey; + let mintAmount: bigint; + + let UserKP: web3.Keypair; + let UserToken: web3.PublicKey; + let recipients: web3.Keypair[]; + let recipientAtas: web3.PublicKey[]; + let totalDepositAmount; + let leaves: any; + let cliffTime; + let blockTime; + + let root: any; + let proof: any; + + before(async () => { + { + await createAndFundWallet(provider.connection); + } + { + const result = await createAndFundWallet(provider.connection); + UserKP = result.keypair; + } + { + const result = await createAndFundBatchWallet(provider.connection); + recipients = result.map((item: any) => item.keypair); + } + + mintAuthority = new web3.Keypair(); + mintKeypair = new web3.Keypair(); + TOKEN = mintKeypair.publicKey; + + mintAmount = BigInt(1_000_000 * Math.pow(10, tokenDecimal)); // Mint 1,000,000 tokens + + // Step 2 - Create a New Token + const mintLamports = + await provider.connection.getMinimumBalanceForRentExemption(82); + const mintTransaction = new Transaction().add( + SystemProgram.createAccount({ + fromPubkey: UserKP.publicKey, + newAccountPubkey: TOKEN, + space: 82, + lamports: mintLamports, + programId: TOKEN_PROGRAM_ID, + }), + createInitializeMint2Instruction( + TOKEN, // Mint account + tokenDecimal, // Decimals + mintAuthority.publicKey, // Mint authority + null, // Freeze authority + TOKEN_PROGRAM_ID // Token program ID + ) + ); + await sendAndConfirmTransaction( + provider.connection, + mintTransaction, + [UserKP, mintKeypair], + undefined + ); + + UserToken = await createAssociatedTokenAccountIdempotent( + provider.connection, + UserKP, + TOKEN, + UserKP.publicKey, + {}, + TOKEN_PROGRAM_ID + ); + + await mintTo( + provider.connection, + UserKP, + TOKEN, + UserToken, + mintAuthority, + mintAmount, + [], + undefined, + TOKEN_PROGRAM_ID + ); + + recipientAtas = []; + for (const recipientor of recipients) { + const recipientorAta = await createAssociatedTokenAccountIdempotent( + provider.connection, + UserKP, + TOKEN, + recipientor.publicKey, + {}, + TOKEN_PROGRAM_ID + ); + recipientAtas.push(recipientorAta); + } + blockTime = await getCurrentBlockTime(provider.connection); + cliffTime = new BN(blockTime).add(new BN(cliffTimeDurraion)); + + // create root & proof + leaves = recipients.map((item) => { + return { + account: item.publicKey, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + cliffTime, + frequency, + vestingStartTime, + }; + }); + const user = { + account: recipients[0].publicKey, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + cliffTime, + frequency, + vestingStartTime, + }; + totalDepositAmount = totalLockedAmount.muln(leaves.length); + root = generateMerkleTreeRoot(leaves); + proof = getMerkleTreeProof(leaves, user); + }); + + it("Close claim status when escrow is cancelled", async () => { + let escrow = await createVestingPlanV3({ + ownerKeypair: UserKP, + tokenMint: TOKEN, + isAssertion: true, + tokenProgram: TOKEN_PROGRAM_ID, + totalDepositAmount, + cancelMode: 1, + root, + }); + + const recipientNode = { + account: recipients[0].publicKey, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + cliffTime, + frequency, + vestingStartTime, + }; + const recipientProof = getMerkleTreeProof(leaves, recipientNode); + + const claimParams = { + recipient: recipients[0], + recipientToken: recipientAtas[0], + tokenMint: TOKEN, + escrow, + maxAmount: new BN(100_000), + isAssertion: false, + tokenProgram: TOKEN_PROGRAM_ID, + proof: recipientProof, + vestingStartTime, + cliffTime, + frequency, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + }; + await claimTokenV3(claimParams); + + await cancelVestingPlanV3( + { + escrow, + isAssertion: true, + rentReceiver: UserKP.publicKey, + creatorToken: UserToken, + signer: UserKP, + }, + 0, + 200_000 + ); + + console.log("Close claim status"); + + await closeClaimStatus({ + escrow, + recipient: recipients[0], + isAssertion: true, + rentReceiver: UserKP.publicKey, + }); + }); + }); + + describe("Close claim status with token2022", () => { + let TOKEN: web3.PublicKey; + let UserKP: web3.Keypair; + let UserToken: web3.PublicKey; + let recipients: web3.Keypair[]; + let recipientAtas: web3.PublicKey[]; + let extensions: ExtensionType[]; + let totalDepositAmount; + let blockTime; + let cliffTime; + + let leaves: any; + let root: any; + let proof: any; + before(async () => { + { + await createAndFundWallet(provider.connection); + } + { + const result = await createAndFundWallet(provider.connection); + UserKP = result.keypair; + } + { + const result = await createAndFundBatchWallet(provider.connection); + recipients = result.map((item: any) => item.keypair); + } + + // Define the extensions to be used by the mint + extensions = [ + ExtensionType.TransferFeeConfig, + ExtensionType.TransferHook, + ]; + + TOKEN = await createMintTransaction( + provider, + UserKP, + extensions, + true, + false + ); + UserToken = await getAssociatedTokenAddress( + TOKEN, + UserKP.publicKey, + true, + TOKEN_2022_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID + ); + + recipientAtas = []; + for (const recipientor of recipients) { + const recipientorAta = await createAssociatedTokenAccountIdempotent( + provider.connection, + UserKP, + TOKEN, + recipientor.publicKey, + {}, + TOKEN_2022_PROGRAM_ID + ); + recipientAtas.push(recipientorAta); + } + + blockTime = await getCurrentBlockTime(provider.connection); + cliffTime = new BN(blockTime).add(new BN(cliffTimeDurraion)); + + // create root & proof + leaves = recipients.map((item) => { + return { + account: item.publicKey, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + cliffTime, + frequency, + vestingStartTime, + }; + }); + const user = { + account: recipients[0].publicKey, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + cliffTime, + frequency, + vestingStartTime, + }; + totalDepositAmount = totalLockedAmount.muln(leaves.length); + root = generateMerkleTreeRoot(leaves); + proof = getMerkleTreeProof(leaves, user); + }); + + it("Close claim status when escrow is cancelled", async () => { + let escrow = await createVestingPlanV3({ + ownerKeypair: UserKP, + tokenMint: TOKEN, + isAssertion: true, + tokenProgram: TOKEN_2022_PROGRAM_ID, + totalDepositAmount, + cancelMode: 1, + root, + }); + + // wait until vesting is over + while (true) { + const currentBlockTime = await getCurrentBlockTime(provider.connection); + if ( + currentBlockTime > + blockTime + + cliffTimeDurraion + + frequency.toNumber() * numberOfPeriod.toNumber() + ) { + break; + } else { + await sleep(1000); + console.log("Wait until vesting over"); + } + } + + const recipientNode = { + account: recipients[0].publicKey, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + cliffTime, + frequency, + vestingStartTime, + }; + const recipientProof = getMerkleTreeProof(leaves, recipientNode); + + try { + const claimParams = { + recipient: recipients[0], + recipientToken: recipientAtas[0], + tokenMint: TOKEN, + escrow, + maxAmount: new BN(100_000), + isAssertion: false, + tokenProgram: TOKEN_2022_PROGRAM_ID, + proof: recipientProof, + vestingStartTime, + cliffTime, + frequency, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + }; + await claimTokenV3(claimParams); + } catch (error) { + console.log(error); + } + + await cancelVestingPlanV3( + { + escrow, + isAssertion: true, + rentReceiver: UserKP.publicKey, + creatorToken: UserToken, + signer: UserKP, + }, + 0, + 200_000 + ); + + console.log("Close claim status"); + + await closeClaimStatus({ + escrow, + recipient: recipients[0], + isAssertion: true, + rentReceiver: UserKP.publicKey, + }); + }); + }); +}); diff --git a/tests/v3/test_create_lock_spl_token.ts b/tests/v3/test_create_lock_spl_token.ts new file mode 100644 index 0000000..fb2b3b7 --- /dev/null +++ b/tests/v3/test_create_lock_spl_token.ts @@ -0,0 +1,471 @@ +import * as anchor from "@coral-xyz/anchor"; +import { web3 } from "@coral-xyz/anchor"; +import { + createAssociatedTokenAccountIdempotent, + createInitializeMint2Instruction, + mintTo, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; +import { BN } from "bn.js"; +import { + createAndFundBatchWallet, + createAndFundWallet, + getCurrentBlockTime, + invokeAndAssertError, + sleep, +} from "../common"; +import { createVestingPlanV3, claimTokenV3 } from "../locker_utils"; +import { + sendAndConfirmTransaction, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import { + generateMerkleTreeRoot, + getMerkleTreeProof, +} from "../locker_utils/merkle_tree"; + +const provider = anchor.AnchorProvider.env(); + +describe("[V3] Create vesting with spl token", () => { + const tokenDecimal = 8; + let mintAuthority: web3.Keypair; + let mintKeypair: web3.Keypair; + let TOKEN: web3.PublicKey; + let mintAmount: bigint; + + let UserKP: web3.Keypair; + let recipients: web3.Keypair[]; + let recipientAtas: web3.PublicKey[]; + let totalDepositAmount; + let vestingStartTime; + let cliffTime; + let frequency; + let cliffUnlockAmount; + let amountPerPeriod; + let numberOfPeriod; + let totalLockedAmount; + let leaves: any; + + let root: any; + let proof: any; + + before(async () => { + { + await createAndFundWallet(provider.connection); + } + { + const result = await createAndFundWallet(provider.connection); + UserKP = result.keypair; + } + { + const result = await createAndFundBatchWallet(provider.connection); + recipients = result.map((item: any) => item.keypair); + } + + mintAuthority = new web3.Keypair(); + mintKeypair = new web3.Keypair(); + TOKEN = mintKeypair.publicKey; + + mintAmount = BigInt(1_000_000 * Math.pow(10, tokenDecimal)); // Mint 1,000,000 tokens + + // Step 2 - Create a New Token + const mintLamports = + await provider.connection.getMinimumBalanceForRentExemption(82); + const mintTransaction = new Transaction().add( + SystemProgram.createAccount({ + fromPubkey: UserKP.publicKey, + newAccountPubkey: TOKEN, + space: 82, + lamports: mintLamports, + programId: TOKEN_PROGRAM_ID, + }), + createInitializeMint2Instruction( + TOKEN, // Mint account + tokenDecimal, // Decimals + mintAuthority.publicKey, // Mint authority + null, // Freeze authority + TOKEN_PROGRAM_ID // Token program ID + ) + ); + await sendAndConfirmTransaction( + provider.connection, + mintTransaction, + [UserKP, mintKeypair], + undefined + ); + + const userToken = await createAssociatedTokenAccountIdempotent( + provider.connection, + UserKP, + TOKEN, + UserKP.publicKey, + {}, + TOKEN_PROGRAM_ID + ); + + await mintTo( + provider.connection, + UserKP, + TOKEN, + userToken, + mintAuthority, + mintAmount, + [], + undefined, + TOKEN_PROGRAM_ID + ); + + recipientAtas = []; + for (const recipientor of recipients) { + const recipientorAta = await createAssociatedTokenAccountIdempotent( + provider.connection, + UserKP, + TOKEN, + recipientor.publicKey, + {}, + TOKEN_PROGRAM_ID + ); + recipientAtas.push(recipientorAta); + } + + // create root & proof + // default value + vestingStartTime = new BN(0); + cliffTime = new BN(1); + frequency = new BN(1); + cliffUnlockAmount = new BN(100_000); + amountPerPeriod = new BN(50_000); + numberOfPeriod = new BN(2); + totalLockedAmount = cliffUnlockAmount.add( + amountPerPeriod.mul(numberOfPeriod) + ); + leaves = recipients.map((item) => { + return { + account: item.publicKey, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + cliffTime, + frequency, + vestingStartTime, + }; + }); + const user = { + account: recipients[0].publicKey, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + cliffTime, + frequency, + vestingStartTime, + }; + totalDepositAmount = totalLockedAmount.muln(leaves.length); + root = generateMerkleTreeRoot(leaves); + proof = getMerkleTreeProof(leaves, user); + }); + + it("Full flow create Vesting plan", async () => { + let escrow = await createVestingPlanV3({ + ownerKeypair: UserKP, + tokenMint: TOKEN, + isAssertion: true, + tokenProgram: TOKEN_PROGRAM_ID, + totalDepositAmount, + cancelMode: 0, + root, + }); + + while (true) { + const currentBlockTime = await getCurrentBlockTime(provider.connection); + if (currentBlockTime > cliffTime.toNumber()) { + break; + } else { + await sleep(1000); + console.log("Wait until startTime"); + } + } + + try { + const claimParams = { + recipient: recipients[0], + recipientToken: recipientAtas[0], + tokenMint: TOKEN, + escrow, + maxAmount: new BN(100_000), + isAssertion: true, + tokenProgram: TOKEN_PROGRAM_ID, + proof, + vestingStartTime, + cliffTime, + frequency, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + }; + + await claimTokenV3(claimParams); + } catch (error) { + console.log(error); + } + }); + + it("All recipients able to claim spl token", async () => { + let escrow = await createVestingPlanV3({ + ownerKeypair: UserKP, + tokenMint: TOKEN, + isAssertion: true, + tokenProgram: TOKEN_PROGRAM_ID, + totalDepositAmount, + cancelMode: 0, + root, + }); + + while (true) { + const currentBlockTime = await getCurrentBlockTime(provider.connection); + if (currentBlockTime > cliffTime.toNumber()) { + break; + } else { + await sleep(1000); + console.log("Wait until startTime"); + } + } + + for (let i = 0; i < recipients.length; i++) { + const recipient = recipients[i]; + const recipientAta = recipientAtas[i]; + const recipientNode = { + account: recipient.publicKey, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + cliffTime, + frequency, + vestingStartTime, + }; + const recipientProof = getMerkleTreeProof(leaves, recipientNode); + try { + const claimParams = { + recipient: recipient, + recipientToken: recipientAta, + tokenMint: TOKEN, + escrow, + maxAmount: new BN(100_000), + isAssertion: true, + tokenProgram: TOKEN_PROGRAM_ID, + proof: recipientProof, + vestingStartTime, + cliffTime, + frequency, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + }; + await claimTokenV3(claimParams); + } catch (error) { + console.log(error); + } + } + }); + + it("Fake recipient can not claim", async () => { + let escrow = await createVestingPlanV3({ + ownerKeypair: UserKP, + tokenMint: TOKEN, + isAssertion: true, + tokenProgram: TOKEN_PROGRAM_ID, + totalDepositAmount, + cancelMode: 0, + root, + }); + + while (true) { + const currentBlockTime = await getCurrentBlockTime(provider.connection); + if (currentBlockTime > cliffTime.toNumber()) { + break; + } else { + await sleep(1000); + console.log("Wait until startTime"); + } + } + + const newRecipient = web3.Keypair.generate(); + const newRecipientAta = await createAssociatedTokenAccountIdempotent( + provider.connection, + UserKP, + TOKEN, + newRecipient.publicKey, + {}, + TOKEN_PROGRAM_ID + ); + const recipientNode = { + account: newRecipient.publicKey, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + cliffTime, + frequency, + vestingStartTime, + }; + const newRecipientProof = getMerkleTreeProof(leaves, recipientNode); + + invokeAndAssertError( + async () => { + const claimParams = { + recipient: newRecipient, + recipientToken: newRecipientAta, + tokenMint: TOKEN, + escrow, + maxAmount: new BN(100_000), + isAssertion: true, + tokenProgram: TOKEN_PROGRAM_ID, + proof: newRecipientProof, + vestingStartTime, + cliffTime, + frequency, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + }; + await claimTokenV3(claimParams); + }, + "Invalid merkle proof", + true + ); + }); + + it("recipient able to claim to another recipient token account", async () => { + let escrow = await createVestingPlanV3({ + ownerKeypair: UserKP, + tokenMint: TOKEN, + isAssertion: true, + tokenProgram: TOKEN_PROGRAM_ID, + totalDepositAmount, + cancelMode: 0, + root, + }); + + while (true) { + const currentBlockTime = await getCurrentBlockTime(provider.connection); + if (currentBlockTime > cliffTime.toNumber()) { + break; + } else { + await sleep(1000); + console.log("Wait until startTime"); + } + } + + const newRecipient = web3.Keypair.generate(); + const newRecipientorAta = await createAssociatedTokenAccountIdempotent( + provider.connection, + UserKP, + TOKEN, + newRecipient.publicKey, + {}, + TOKEN_PROGRAM_ID + ); + + try { + const claimParams = { + recipient: recipients[0], + recipientToken: newRecipientorAta, + tokenMint: TOKEN, + escrow, + maxAmount: new BN(100_000), + isAssertion: true, + tokenProgram: TOKEN_PROGRAM_ID, + proof, + vestingStartTime, + cliffTime, + frequency, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + }; + + await claimTokenV3(claimParams); + } catch (error) { + console.log(error); + } + }); + + it("recipient claim more than one time", async () => { + let escrow = await createVestingPlanV3({ + ownerKeypair: UserKP, + tokenMint: TOKEN, + isAssertion: true, + tokenProgram: TOKEN_PROGRAM_ID, + totalDepositAmount, + cancelMode: 0, + root, + }); + + while (true) { + const currentBlockTime = await getCurrentBlockTime(provider.connection); + if (currentBlockTime > cliffTime.toNumber()) { + break; + } else { + await sleep(1000); + console.log("Wait until startTime"); + } + } + + // first time claim + try { + const claimParams = { + recipient: recipients[0], + recipientToken: recipientAtas[0], + tokenMint: TOKEN, + escrow, + maxAmount: new BN(100_000), + isAssertion: true, + tokenProgram: TOKEN_PROGRAM_ID, + proof, + vestingStartTime, + cliffTime, + frequency, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + }; + + await claimTokenV3(claimParams); + } catch (error) { + console.log(error); + } + + while (true) { + const currentBlockTime = await getCurrentBlockTime(provider.connection); + if (currentBlockTime > cliffTime.toNumber()) { + break; + } else { + await sleep(1000); + console.log("Wait until startTime"); + } + } + + // 2nd claim + try { + const claimParams = { + recipient: recipients[0], + recipientToken: recipientAtas[0], + tokenMint: TOKEN, + escrow, + maxAmount: new BN(100_000), + isAssertion: true, + tokenProgram: TOKEN_PROGRAM_ID, + proof, + vestingStartTime, + cliffTime, + frequency, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + }; + + await claimTokenV3(claimParams); + } catch (error) { + console.log(error); + } + }); +}); diff --git a/tests/v3/test_create_lock_token2022.ts b/tests/v3/test_create_lock_token2022.ts new file mode 100644 index 0000000..100958c --- /dev/null +++ b/tests/v3/test_create_lock_token2022.ts @@ -0,0 +1,413 @@ +import * as anchor from "@coral-xyz/anchor"; +import { web3 } from "@coral-xyz/anchor"; +import { + createAssociatedTokenAccountIdempotent, + ExtensionType, + TOKEN_2022_PROGRAM_ID, +} from "@solana/spl-token"; +import { BN } from "bn.js"; +import { + createAndFundBatchWallet, + createAndFundWallet, + getCurrentBlockTime, + invokeAndAssertError, + sleep, +} from "../common"; +import { + claimTokenV3, + createLockerProgram, + createVestingPlanV3, +} from "../locker_utils"; +import { ADMIN, createMintTransaction } from "../locker_utils/token_2022/mint"; +import { + generateMerkleTreeRoot, + getMerkleTreeProof, +} from "../locker_utils/merkle_tree"; + +const provider = anchor.AnchorProvider.env(); + +describe("[V3] Create vesting with Token2022", () => { + let TOKEN: web3.PublicKey; + let UserKP: web3.Keypair; + let recipients: web3.Keypair[]; + let recipientAtas: web3.PublicKey[]; + let totalDepositAmount; + let vestingStartTime; + let cliffTime; + let frequency; + let cliffUnlockAmount; + let amountPerPeriod; + let numberOfPeriod; + let totalLockedAmount; + let leaves: any; + + let extensions: ExtensionType[]; + let root: any; + let proof: any; + + before(async () => { + { + await createAndFundWallet(provider.connection, ADMIN); + } + { + const result = await createAndFundWallet(provider.connection); + UserKP = result.keypair; + } + { + const result = await createAndFundBatchWallet(provider.connection); + recipients = result.map((item: any) => item.keypair); + } + + // Define the extensions to be used by the mint + extensions = [ExtensionType.TransferFeeConfig, ExtensionType.TransferHook]; + + TOKEN = await createMintTransaction( + provider, + UserKP, + extensions, + true, + false + ); + recipientAtas = []; + for (const recipientor of recipients) { + const recipientorAta = await createAssociatedTokenAccountIdempotent( + provider.connection, + UserKP, + TOKEN, + recipientor.publicKey, + {}, + TOKEN_2022_PROGRAM_ID + ); + recipientAtas.push(recipientorAta); + } + + // create root & proof + // default value + vestingStartTime = new BN(0); + let currentBlockTime = await getCurrentBlockTime(provider.connection); + cliffTime = new BN(currentBlockTime).add(new BN(5)); + frequency = new BN(2); + cliffUnlockAmount = new BN(100_000); + amountPerPeriod = new BN(50_000); + numberOfPeriod = new BN(2); + totalLockedAmount = cliffUnlockAmount.add( + amountPerPeriod.mul(numberOfPeriod) + ); + leaves = recipients.map((item) => { + return { + account: item.publicKey, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + cliffTime, + frequency, + vestingStartTime, + }; + }); + const user = { + account: recipients[0].publicKey, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + cliffTime, + frequency, + vestingStartTime, + }; + totalDepositAmount = totalLockedAmount.muln(leaves.length); + root = generateMerkleTreeRoot(leaves); + proof = getMerkleTreeProof(leaves, user); + }); + + it("Full flow Create Vesting plan", async () => { + let escrow = await createVestingPlanV3({ + ownerKeypair: UserKP, + tokenMint: TOKEN, + isAssertion: true, + tokenProgram: TOKEN_2022_PROGRAM_ID, + totalDepositAmount, + cancelMode: 0, + root, + }); + + try { + const claimParams = { + recipient: recipients[0], + recipientToken: recipientAtas[0], + tokenMint: TOKEN, + escrow, + maxAmount: new BN(100_000), + isAssertion: true, + tokenProgram: TOKEN_2022_PROGRAM_ID, + proof, + vestingStartTime, + cliffTime, + frequency, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + }; + await claimTokenV3(claimParams); + } catch (error) { + console.log(error); + } + }); + + it("All recipients able to claim spl token", async () => { + let escrow = await createVestingPlanV3({ + ownerKeypair: UserKP, + tokenMint: TOKEN, + isAssertion: true, + tokenProgram: TOKEN_2022_PROGRAM_ID, + totalDepositAmount, + cancelMode: 0, + root, + }); + + while (true) { + const currentBlockTime = await getCurrentBlockTime(provider.connection); + if (currentBlockTime > cliffTime.toNumber()) { + break; + } else { + await sleep(1000); + console.log("Wait until startTime"); + } + } + + for (let i = 0; i < recipients.length; i++) { + const recipient = recipients[i]; + const recipientAta = recipientAtas[i]; + const recipientNode = { + account: recipient.publicKey, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + cliffTime, + frequency, + vestingStartTime, + }; + const recipientProof = getMerkleTreeProof(leaves, recipientNode); + try { + const claimParams = { + recipient: recipient, + recipientToken: recipientAta, + tokenMint: TOKEN, + escrow, + maxAmount: new BN(100_000), + isAssertion: true, + tokenProgram: TOKEN_2022_PROGRAM_ID, + proof: recipientProof, + vestingStartTime, + cliffTime, + frequency, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + }; + await claimTokenV3(claimParams); + } catch (error) { + console.log(error); + } + } + }); + + it("Fake recipient can not claim", async () => { + let escrow = await createVestingPlanV3({ + ownerKeypair: UserKP, + tokenMint: TOKEN, + isAssertion: true, + tokenProgram: TOKEN_2022_PROGRAM_ID, + totalDepositAmount, + cancelMode: 0, + root, + }); + + while (true) { + const currentBlockTime = await getCurrentBlockTime(provider.connection); + if (currentBlockTime > cliffTime.toNumber()) { + break; + } else { + await sleep(1000); + console.log("Wait until startTime"); + } + } + + const newRecipient = web3.Keypair.generate(); + const newRecipientAta = await createAssociatedTokenAccountIdempotent( + provider.connection, + UserKP, + TOKEN, + newRecipient.publicKey, + {}, + TOKEN_2022_PROGRAM_ID + ); + const recipientNode = { + account: newRecipient.publicKey, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + cliffTime, + frequency, + vestingStartTime, + }; + const newRecipientProof = getMerkleTreeProof(leaves, recipientNode); + + invokeAndAssertError( + async () => { + const claimParams = { + recipient: newRecipient, + recipientToken: newRecipientAta, + tokenMint: TOKEN, + escrow, + maxAmount: new BN(1_000_000), + isAssertion: false, + tokenProgram: TOKEN_2022_PROGRAM_ID, + proof: newRecipientProof, + vestingStartTime, + cliffTime, + frequency, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + }; + await claimTokenV3(claimParams); + }, + "Invalid merkle proof", + true + ); + }); + + it("recipient able to claim to another recipient token account", async () => { + let escrow = await createVestingPlanV3({ + ownerKeypair: UserKP, + tokenMint: TOKEN, + isAssertion: true, + tokenProgram: TOKEN_2022_PROGRAM_ID, + totalDepositAmount, + cancelMode: 0, + root, + }); + + while (true) { + const currentBlockTime = await getCurrentBlockTime(provider.connection); + if (currentBlockTime > cliffTime.toNumber()) { + break; + } else { + await sleep(1000); + console.log("Wait until startTime"); + } + } + + const newRecipient = web3.Keypair.generate(); + const newRecipientorAta = await createAssociatedTokenAccountIdempotent( + provider.connection, + UserKP, + TOKEN, + newRecipient.publicKey, + {}, + TOKEN_2022_PROGRAM_ID + ); + + try { + const claimParams = { + recipient: recipients[0], + recipientToken: newRecipientorAta, + tokenMint: TOKEN, + escrow, + maxAmount: new BN(100_000), + isAssertion: true, + tokenProgram: TOKEN_2022_PROGRAM_ID, + proof, + vestingStartTime, + cliffTime, + frequency, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + }; + + await claimTokenV3(claimParams); + } catch (error) { + console.log(error); + } + }); + + it("recipient claim more than one time", async () => { + let escrow = await createVestingPlanV3({ + ownerKeypair: UserKP, + tokenMint: TOKEN, + isAssertion: true, + tokenProgram: TOKEN_2022_PROGRAM_ID, + totalDepositAmount, + cancelMode: 0, + root, + }); + + while (true) { + const currentBlockTime = await getCurrentBlockTime(provider.connection); + if (currentBlockTime > cliffTime.toNumber()) { + break; + } else { + await sleep(1000); + console.log("Wait until startTime"); + } + } + + // first time claim + try { + const claimParams = { + recipient: recipients[0], + recipientToken: recipientAtas[0], + tokenMint: TOKEN, + escrow, + maxAmount: new BN(100_000), + isAssertion: true, + tokenProgram: TOKEN_2022_PROGRAM_ID, + proof, + vestingStartTime, + cliffTime, + frequency, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + }; + + await claimTokenV3(claimParams); + } catch (error) { + console.log(error); + } + + while (true) { + const currentBlockTime = await getCurrentBlockTime(provider.connection); + if (currentBlockTime > cliffTime.toNumber()) { + break; + } else { + await sleep(1000); + console.log("Wait until startTime"); + } + } + + // 2nd claim + try { + const claimParams = { + recipient: recipients[0], + recipientToken: recipientAtas[0], + tokenMint: TOKEN, + escrow, + maxAmount: new BN(100_000), + isAssertion: true, + tokenProgram: TOKEN_2022_PROGRAM_ID, + proof, + vestingStartTime, + cliffTime, + frequency, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + }; + + await claimTokenV3(claimParams); + } catch (error) { + console.log(error); + } + }); +}); diff --git a/tests/v3/test_escrow_metadata.ts b/tests/v3/test_escrow_metadata.ts new file mode 100644 index 0000000..5af2cb3 --- /dev/null +++ b/tests/v3/test_escrow_metadata.ts @@ -0,0 +1,174 @@ +import * as anchor from "@coral-xyz/anchor"; +import { web3 } from "@coral-xyz/anchor"; +import { + createAssociatedTokenAccountIdempotent, + createInitializeMint2Instruction, + mintTo, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; +import { BN } from "bn.js"; +import { + createAndFundBatchWallet, + createAndFundWallet, + getCurrentBlockTime, +} from "../common"; +import { createVestingPlanV3, createEscrowMetadata } from "../locker_utils"; +import { + sendAndConfirmTransaction, + SystemProgram, + Transaction, +} from "@solana/web3.js"; +import { + generateMerkleTreeRoot, + getMerkleTreeProof, +} from "../locker_utils/merkle_tree"; + +const provider = anchor.AnchorProvider.env(); + +describe("[V3] Create vesting metadata", () => { + const tokenDecimal = 8; + let mintAuthority: web3.Keypair; + let mintKeypair: web3.Keypair; + let TOKEN: web3.PublicKey; + let mintAmount: bigint; + + let UserKP: web3.Keypair; + let recipients: web3.Keypair[]; + let totalDepositAmount; + let vestingStartTime; + let cliffTime; + let frequency; + let cliffUnlockAmount; + let amountPerPeriod; + let numberOfPeriod; + let totalLockedAmount; + let leaves: any; + + let root: any; + let proof: any; + + before(async () => { + { + await createAndFundWallet(provider.connection); + } + { + const result = await createAndFundWallet(provider.connection); + UserKP = result.keypair; + } + { + const result = await createAndFundBatchWallet(provider.connection); + recipients = result.map((item: any) => item.keypair); + } + + mintAuthority = new web3.Keypair(); + mintKeypair = new web3.Keypair(); + TOKEN = mintKeypair.publicKey; + + mintAmount = BigInt(1_000_000 * Math.pow(10, tokenDecimal)); // Mint 1,000,000 tokens + + // Step 2 - Create a New Token + const mintLamports = + await provider.connection.getMinimumBalanceForRentExemption(82); + const mintTransaction = new Transaction().add( + SystemProgram.createAccount({ + fromPubkey: UserKP.publicKey, + newAccountPubkey: TOKEN, + space: 82, + lamports: mintLamports, + programId: TOKEN_PROGRAM_ID, + }), + createInitializeMint2Instruction( + TOKEN, // Mint account + tokenDecimal, // Decimals + mintAuthority.publicKey, // Mint authority + null, // Freeze authority + TOKEN_PROGRAM_ID // Token program ID + ) + ); + await sendAndConfirmTransaction( + provider.connection, + mintTransaction, + [UserKP, mintKeypair], + undefined + ); + + const userToken = await createAssociatedTokenAccountIdempotent( + provider.connection, + UserKP, + TOKEN, + UserKP.publicKey, + {}, + TOKEN_PROGRAM_ID + ); + + await mintTo( + provider.connection, + UserKP, + TOKEN, + userToken, + mintAuthority, + mintAmount, + [], + undefined, + TOKEN_PROGRAM_ID + ); + + // create root & proof + // default value + vestingStartTime = new BN(0); + let currentBlockTime = await getCurrentBlockTime(provider.connection); + cliffTime = new BN(currentBlockTime).add(new BN(5)); + frequency = new BN(1); + cliffUnlockAmount = new BN(100_000); + amountPerPeriod = new BN(50_000); + numberOfPeriod = new BN(2); + totalLockedAmount = cliffUnlockAmount.add( + amountPerPeriod.mul(numberOfPeriod) + ); + leaves = recipients.map((item) => { + return { + account: item.publicKey, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + cliffTime, + frequency, + vestingStartTime + }; + }); + const user = { + account: recipients[0].publicKey, + cliffUnlockAmount, + amountPerPeriod, + numberOfPeriod, + cliffTime, + frequency, + vestingStartTime + }; + totalDepositAmount = totalLockedAmount.muln(leaves.length); + root = generateMerkleTreeRoot(leaves); + proof = getMerkleTreeProof(leaves, user); + }); + + it("Full flow", async () => { + let escrow = await createVestingPlanV3({ + ownerKeypair: UserKP, + tokenMint: TOKEN, + isAssertion: true, + tokenProgram: TOKEN_PROGRAM_ID, + totalDepositAmount, + cancelMode: 0, + root, + }); + console.log("Create escrow metadata"); + await createEscrowMetadata({ + escrow, + name: "Jupiter lock", + description: "This is jupiter lock", + creatorEmail: "andrew@raccoons.dev", + recipientEndpoint: "", + creator: UserKP, + isAssertion: true, + }); + }); +});