diff --git a/programs/gateway/src/contexts.rs b/programs/gateway/src/contexts.rs index 4d8cb50..41f1585 100644 --- a/programs/gateway/src/contexts.rs +++ b/programs/gateway/src/contexts.rs @@ -313,6 +313,43 @@ pub struct Unwhitelist<'info> { pub whitelist_candidate: Account<'info, Mint>, } +/// Instruction context for refunding SPL tokens from gateway custody. +#[derive(Accounts)] +pub struct RefundSplToken<'info> { + /// The authority account refunding tokens from custody. + #[account(mut)] + pub signer: Signer<'info>, + + /// Gateway PDA. + #[account(seeds = [b"meta"], bump)] + pub pda: Account<'info, Pda>, + + /// The associated token account for the Gateway PDA. + #[account(mut, associated_token::mint = mint_account, associated_token::authority = pda)] + pub pda_ata: Account<'info, TokenAccount>, + + /// The mint account of the SPL token being refunded. + pub mint_account: Account<'info, Mint>, + + /// The recipient wallet receiving the refunded tokens. + /// CHECK: Only the pubkey is used to derive the expected ATA; account data is not validated. + pub recipient: UncheckedAccount<'info>, + + /// The recipient's associated token account. + /// CHECK: Validation will occur during instruction processing. + #[account(mut)] + pub recipient_ata: AccountInfo<'info>, + + /// The token program. + pub token_program: Program<'info, Token>, + + /// The associated token program. + pub associated_token_program: Program<'info, AssociatedToken>, + + /// The system program. + pub system_program: Program<'info, System>, +} + /// Instruction context for checking upgrade status #[derive(Accounts)] pub struct Upgrade<'info> { diff --git a/programs/gateway/src/errors.rs b/programs/gateway/src/errors.rs index 3d50c49..d8a9dec 100644 --- a/programs/gateway/src/errors.rs +++ b/programs/gateway/src/errors.rs @@ -23,4 +23,8 @@ pub enum Errors { InvalidInstructionData, #[msg("InvalidAtaOwner")] InvalidAtaOwner, + #[msg("InsufficientBalance")] + InsufficientBalance, + #[msg("InvalidAmount")] + InvalidAmount, } diff --git a/programs/gateway/src/instructions/mod.rs b/programs/gateway/src/instructions/mod.rs index 0b758cf..50aeffa 100644 --- a/programs/gateway/src/instructions/mod.rs +++ b/programs/gateway/src/instructions/mod.rs @@ -1,4 +1,5 @@ pub mod admin; pub mod deposit; pub mod execute; +pub mod refund; pub mod withdraw; diff --git a/programs/gateway/src/instructions/refund.rs b/programs/gateway/src/instructions/refund.rs new file mode 100644 index 0000000..4d4e314 --- /dev/null +++ b/programs/gateway/src/instructions/refund.rs @@ -0,0 +1,100 @@ +use crate::{ + contexts::RefundSplToken, + errors::Errors, + utils::{verify_ata_match, verify_authority}, +}; +use anchor_lang::prelude::*; +use anchor_lang::solana_program::program::invoke; +use anchor_spl::token::transfer_checked; +use spl_associated_token_account::instruction::create_associated_token_account; + +// Refunds SPL tokens from gateway custody to a user. Caller is authority stored in PDA. +pub fn handle_spl(ctx: Context, amount: u64, decimals: u8) -> Result<()> { + let pda = &ctx.accounts.pda; + + verify_authority(&ctx.accounts.signer.key(), pda)?; + require!(amount > 0, Errors::InvalidAmount); + + verify_ata_match( + &pda.key(), + &ctx.accounts.mint_account.key(), + &ctx.accounts.pda_ata.key(), + )?; + + verify_ata_match( + &ctx.accounts.recipient.key(), + &ctx.accounts.mint_account.key(), + &ctx.accounts.recipient_ata.key(), + )?; + + require!( + ctx.accounts.pda_ata.amount >= amount, + Errors::InsufficientBalance + ); + + let recipient_ata_account = ctx.accounts.recipient_ata.to_account_info(); + require!( + *recipient_ata_account.owner == anchor_spl::token::ID + || (*recipient_ata_account.owner == anchor_lang::system_program::ID + && recipient_ata_account.lamports() == 0), + Errors::InvalidAtaOwner + ); + + if recipient_ata_account.lamports() == 0 { + msg!( + "Creating associated token account {:?} for recipient {:?}...", + recipient_ata_account.key(), + ctx.accounts.recipient.key(), + ); + + invoke( + &create_associated_token_account( + ctx.accounts.signer.to_account_info().key, + ctx.accounts.recipient.to_account_info().key, + ctx.accounts.mint_account.to_account_info().key, + ctx.accounts.token_program.key, + ), + &[ + ctx.accounts.mint_account.to_account_info().clone(), + ctx.accounts.recipient_ata.clone(), + ctx.accounts.recipient.to_account_info().clone(), + ctx.accounts.signer.to_account_info().clone(), + ctx.accounts.system_program.to_account_info().clone(), + ctx.accounts.token_program.to_account_info().clone(), + ctx.accounts + .associated_token_program + .to_account_info() + .clone(), + ], + )?; + + msg!("Associated token account created!"); + } + + let token = &ctx.accounts.token_program; + let signer_seeds: &[&[&[u8]]] = &[&[b"meta", &[ctx.bumps.pda]]]; + + let xfer_ctx = CpiContext::new_with_signer( + token.to_account_info(), + anchor_spl::token::TransferChecked { + from: ctx.accounts.pda_ata.to_account_info(), + mint: ctx.accounts.mint_account.to_account_info(), + to: ctx.accounts.recipient_ata.to_account_info(), + authority: pda.to_account_info(), + }, + signer_seeds, + ); + + transfer_checked(xfer_ctx, amount, decimals)?; + + msg!( + "Refund SPL executed: amount = {}, decimals = {}, recipient = {}, mint = {}, authority = {}", + amount, + decimals, + ctx.accounts.recipient.key(), + ctx.accounts.mint_account.key(), + ctx.accounts.signer.key() + ); + + Ok(()) +} diff --git a/programs/gateway/src/lib.rs b/programs/gateway/src/lib.rs index 574ecdc..caf21fb 100644 --- a/programs/gateway/src/lib.rs +++ b/programs/gateway/src/lib.rs @@ -380,6 +380,15 @@ pub mod gateway { instructions::withdraw::handle_sol(ctx, amount, signature, recovery_id, message_hash, nonce) } + /// Refunds SPL tokens from gateway custody to a user. Caller is authority stored in PDA. + /// # Arguments + /// * `ctx` - The instruction context. + /// * `amount` - The amount of tokens to refund. + /// * `decimals` - Token decimals for precision. + pub fn refund_spl_token(ctx: Context, amount: u64, decimals: u8) -> Result<()> { + instructions::refund::handle_spl(ctx, amount, decimals) + } + /// Withdraws SPL tokens. Caller is TSS. /// # Arguments /// * `ctx` - The instruction context. diff --git a/tests/gateway.ts b/tests/gateway.ts index 95b284e..cfb380a 100644 --- a/tests/gateway.ts +++ b/tests/gateway.ts @@ -161,6 +161,40 @@ async function withdrawSplToken( .rpc({ commitment: "processed" }); } +async function refundSplToken( + gatewayProgram: Program, + mint: anchor.web3.PublicKey, + decimals: number, + amount: anchor.BN, + recipient: anchor.web3.PublicKey, + recipientAta: anchor.web3.PublicKey, + signer: anchor.web3.Keypair = anchor.workspace.Gateway.provider.wallet + .payer +) { + const [pdaAccount] = anchor.web3.PublicKey.findProgramAddressSync( + [Buffer.from("meta", "utf-8")], + gatewayProgram.programId + ); + const pdaAta = await spl.getAssociatedTokenAddress( + mint, + pdaAccount, + true + ); + + return gatewayProgram.methods + .refundSplToken(amount, decimals) + .accounts({ + signer: signer.publicKey, + pda: pdaAccount, + pdaAta, + mintAccount: mint, + recipient, + recipientAta, + }) + .signers(signer.publicKey.equals(anchor.workspace.Gateway.provider.wallet.publicKey) ? [] : [signer]) + .rpc({ commitment: "processed" }); +} + describe("Gateway", () => { // Configure the client to use the local cluster. anchor.setProvider(anchor.AnchorProvider.env()); @@ -3911,6 +3945,164 @@ describe("Gateway", () => { } }); + it("Refund SPL token to user", async () => { + const refundAmount = new anchor.BN(100_000); + const pdaAta = await spl.getAssociatedTokenAddress( + mint.publicKey, + pdaAccount, + true + ); + const custodyBefore = await spl.getAccount(conn, pdaAta); + const recipientBefore = await spl.getAccount(conn, wallet_ata); + + await refundSplToken( + gatewayProgram, + mint.publicKey, + usdcDecimals, + refundAmount, + wallet.publicKey, + wallet_ata + ); + + const custodyAfter = await spl.getAccount(conn, pdaAta); + const recipientAfter = await spl.getAccount(conn, wallet_ata); + expect(custodyBefore.amount - custodyAfter.amount).to.equal( + BigInt(refundAmount.toNumber()) + ); + expect(recipientAfter.amount - recipientBefore.amount).to.equal( + BigInt(refundAmount.toNumber()) + ); + }); + + it("Refund SPL token fails for non-authority", async () => { + try { + await refundSplToken( + gatewayProgram, + mint.publicKey, + usdcDecimals, + new anchor.BN(1), + wallet.publicKey, + wallet_ata, + random_account + ); + throw new Error("Expected error not thrown"); + } catch (err) { + expect(err).to.be.instanceof(anchor.AnchorError); + expect(err.message).to.include("SignerIsNotAuthority"); + } + }); + + it("Refund SPL token fails for insufficient balance", async () => { + const pdaAta = await spl.getAssociatedTokenAddress( + mint.publicKey, + pdaAccount, + true + ); + const custody = await spl.getAccount(conn, pdaAta); + + try { + await refundSplToken( + gatewayProgram, + mint.publicKey, + usdcDecimals, + new anchor.BN(custody.amount.toString()).addn(1), + wallet.publicKey, + wallet_ata + ); + throw new Error("Expected error not thrown"); + } catch (err) { + expect(err).to.be.instanceof(anchor.AnchorError); + expect(err.message).to.include("InsufficientBalance"); + } + }); + + it("Refund SPL token fails for zero amount", async () => { + try { + await refundSplToken( + gatewayProgram, + mint.publicKey, + usdcDecimals, + new anchor.BN(0), + wallet.publicKey, + wallet_ata + ); + throw new Error("Expected error not thrown"); + } catch (err) { + expect(err).to.be.instanceof(anchor.AnchorError); + expect(err.message).to.include("InvalidAmount"); + } + }); + + it("Refund SPL token creates recipient ATA if needed", async () => { + const refundRecipient = anchor.web3.Keypair.generate(); + const refundAmount = new anchor.BN(50_000); + const recipientAta = await spl.getAssociatedTokenAddress( + mint.publicKey, + refundRecipient.publicKey + ); + + await refundSplToken( + gatewayProgram, + mint.publicKey, + usdcDecimals, + refundAmount, + refundRecipient.publicKey, + recipientAta + ); + + const recipientAccount = await spl.getAccount(conn, recipientAta); + expect(recipientAccount.amount).to.equal(BigInt(refundAmount.toNumber())); + }); + + it("Refund SPL token fails for mismatched recipient ATA", async () => { + const wrongRecipientAta = await spl.getAssociatedTokenAddress( + mint.publicKey, + random_account.publicKey + ); + + try { + await refundSplToken( + gatewayProgram, + mint.publicKey, + usdcDecimals, + new anchor.BN(1), + wallet.publicKey, + wrongRecipientAta + ); + throw new Error("Expected error not thrown"); + } catch (err) { + expect(err).to.be.instanceof(anchor.AnchorError); + expect(err.message).to.include("SPLAtaAndMintAddressMismatch"); + } + }); + + it("Refund SPL token works while deposits are paused", async () => { + await gatewayProgram.methods.setDepositPaused(true).rpc(); + + try { + const refundRecipient = anchor.web3.Keypair.generate(); + const refundAmount = new anchor.BN(25_000); + const recipientAta = await spl.getAssociatedTokenAddress( + mint.publicKey, + refundRecipient.publicKey + ); + + await refundSplToken( + gatewayProgram, + mint.publicKey, + usdcDecimals, + refundAmount, + refundRecipient.publicKey, + recipientAta + ); + + const recipientAccount = await spl.getAccount(conn, recipientAta); + expect(recipientAccount.amount).to.equal(BigInt(refundAmount.toNumber())); + } finally { + await gatewayProgram.methods.setDepositPaused(false).rpc(); + } + }); + it("Update TSS address", async () => { const newTss = new Uint8Array(20); randomFillSync(newTss);