diff --git a/tokens/escrow/anchor/programs/escrow/src/instructions/cancel_offer.rs b/tokens/escrow/anchor/programs/escrow/src/instructions/cancel_offer.rs new file mode 100644 index 000000000..8bed173da --- /dev/null +++ b/tokens/escrow/anchor/programs/escrow/src/instructions/cancel_offer.rs @@ -0,0 +1,92 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + token_2022::{close_account, transfer_checked, CloseAccount, TransferChecked}, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; + +use crate::Offer; + +#[derive(Accounts)] +#[instruction(id: u64)] +pub struct CancelOffer<'a> { + #[account(mut)] + pub maker: Signer<'a>, + + #[account( + mut, + close = maker, + seeds = [b"offer", maker.key().as_ref(), &id.to_le_bytes()], + bump = offer.bump, + has_one = maker, + has_one = token_mint_a + )] + pub offer: Account<'a, Offer>, + + #[account( + mut, + associated_token::authority = offer, + associated_token::mint = token_mint_a, + associated_token::token_program = token_program, + )] + pub vault: InterfaceAccount<'a, TokenAccount>, + + #[account( + mut, + associated_token::mint = token_mint_a, + associated_token::authority = maker, + associated_token::token_program = token_program, + )] + pub maker_token_account_a: InterfaceAccount<'a, TokenAccount>, + + #[account(mint::token_program = token_program)] + pub token_mint_a: InterfaceAccount<'a, Mint>, + + pub token_program: Interface<'a, TokenInterface>, + pub system_program: Program<'a, System>, +} + +pub fn handler(ctx: Context, id: u64) -> Result<()> { + // 1. Transfer Token A from the vault back to the maker + let seeds = &[ + b"offer", + ctx.accounts.maker.to_account_info().key.as_ref(), + &id.to_le_bytes(), + &[ctx.accounts.offer.bump], + ]; + + let signer_seeds = &[&seeds[..]]; + + let transfer_accounts = TransferChecked { + from: ctx.accounts.vault.to_account_info(), + to: ctx.accounts.maker_token_account_a.to_account_info(), + authority: ctx.accounts.offer.to_account_info(), + mint: ctx.accounts.token_mint_a.to_account_info(), + }; + + let cpi_cotext = CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + transfer_accounts, + signer_seeds, + ); + + transfer_checked( + cpi_cotext, + ctx.accounts.vault.amount, + ctx.accounts.token_mint_a.decimals, + )?; + + // 2. Close the vault account and reclaim rent to the maker + let close_accounts = CloseAccount { + account: ctx.accounts.vault.to_account_info(), + destination: ctx.accounts.maker.to_account_info(), + authority: ctx.accounts.offer.to_account_info(), + }; + + let cpi_context = CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + close_accounts, + signer_seeds, + ); + close_account(cpi_context)?; + Ok(()) +} diff --git a/tokens/escrow/anchor/programs/escrow/src/instructions/mod.rs b/tokens/escrow/anchor/programs/escrow/src/instructions/mod.rs index cf1dd986b..c0cd489c3 100644 --- a/tokens/escrow/anchor/programs/escrow/src/instructions/mod.rs +++ b/tokens/escrow/anchor/programs/escrow/src/instructions/mod.rs @@ -6,3 +6,6 @@ pub use take_offer::*; pub mod shared; pub use shared::*; + +pub mod cancel_offer; +pub use cancel_offer::*; diff --git a/tokens/escrow/anchor/programs/escrow/src/lib.rs b/tokens/escrow/anchor/programs/escrow/src/lib.rs index 686b770d6..9068cf43e 100644 --- a/tokens/escrow/anchor/programs/escrow/src/lib.rs +++ b/tokens/escrow/anchor/programs/escrow/src/lib.rs @@ -29,4 +29,8 @@ pub mod escrow { instructions::take_offer::send_wanted_tokens_to_maker(&context)?; instructions::take_offer::withdraw_and_close_vault(context) } + + pub fn cancel_offer(ctx: Context, id: u64) -> Result<()> { + instructions::cancel_offer::handler(ctx, id) + } } diff --git a/tokens/escrow/anchor/tests/escrow.test.ts b/tokens/escrow/anchor/tests/escrow.test.ts index b1d55165b..f54be593a 100644 --- a/tokens/escrow/anchor/tests/escrow.test.ts +++ b/tokens/escrow/anchor/tests/escrow.test.ts @@ -165,4 +165,67 @@ describe('escrow', async () => { const aliceTokenAccountBalanceAfter = new BN(aliceTokenAccountBalanceAfterResponse.value.amount); assert(aliceTokenAccountBalanceAfter.eq(tokenBWantedAmount)); }).slow(ANCHOR_SLOW_TEST_THRESHOLD); + + it('Allows Alice to cancel an offer and reclaim her tokens', async () => { + // Pick a random ID for the offer we'll make + const offerId = getRandomBigNumber(); + + // Then determine the account addresses we'll use for the offer and the vault + const offer = PublicKey.findProgramAddressSync( + [ + Buffer.from('offer'), + accounts.maker.toBuffer(), + offerId.toArrayLike(Buffer, 'le', 8), + ], + program.programId, + )[0]; + + const vault = getAssociatedTokenAddressSync( + accounts.tokenMintA, + offer, + true, + TOKEN_PROGRAM, + ); + + accounts.offer = offer; + accounts.vault = vault; + + // Get initial balance + const initialBalanceResponse = await connection.getTokenAccountBalance( + accounts.makerTokenAccountA, + ); + const initialBalance = new BN(initialBalanceResponse.value.amount); + + // Make the offer + await program.methods + .makeOffer(offerId, tokenAOfferedAmount, tokenBWantedAmount) + .accounts({ ...accounts }) + .signers([alice]) + .rpc(); + + // Check balance decreased + const postOfferBalanceResponse = await connection.getTokenAccountBalance( + accounts.makerTokenAccountA, + ); + const postOfferBalance = new BN(postOfferBalanceResponse.value.amount); + assert(postOfferBalance.eq(initialBalance.sub(tokenAOfferedAmount))); + + // Cancel the offer + await program.methods + .cancelOffer(offerId) + .accounts({ ...accounts }) + .signers([alice]) + .rpc(); + + // Check balance restored + const finalBalanceResponse = await connection.getTokenAccountBalance( + accounts.makerTokenAccountA, + ); + const finalBalance = new BN(finalBalanceResponse.value.amount); + assert(finalBalance.eq(initialBalance)); + + // Check vault account is closed + const vaultAccountInfo = await connection.getAccountInfo(vault); + assert.isNull(vaultAccountInfo, 'Vault account should be closed'); + }).slow(ANCHOR_SLOW_TEST_THRESHOLD); });