Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions programs/gateway/src/contexts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
4 changes: 4 additions & 0 deletions programs/gateway/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,8 @@ pub enum Errors {
InvalidInstructionData,
#[msg("InvalidAtaOwner")]
InvalidAtaOwner,
#[msg("InsufficientBalance")]
InsufficientBalance,
#[msg("InvalidAmount")]
InvalidAmount,
}
1 change: 1 addition & 0 deletions programs/gateway/src/instructions/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod admin;
pub mod deposit;
pub mod execute;
pub mod refund;
pub mod withdraw;
100 changes: 100 additions & 0 deletions programs/gateway/src/instructions/refund.rs
Original file line number Diff line number Diff line change
@@ -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<RefundSplToken>, amount: u64, decimals: u8) -> Result<()> {
let pda = &ctx.accounts.pda;

verify_authority(&ctx.accounts.signer.key(), pda)?;
Comment thread
s2imonovic marked this conversation as resolved.
Resolved
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 {
Comment thread
s2imonovic marked this conversation as resolved.
Resolved
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(),
],
)?;
Comment thread
s2imonovic marked this conversation as resolved.
Resolved

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(())
}
9 changes: 9 additions & 0 deletions programs/gateway/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RefundSplToken>, amount: u64, decimals: u8) -> Result<()> {
instructions::refund::handle_spl(ctx, amount, decimals)
}

/// Withdraws SPL tokens. Caller is TSS.
/// # Arguments
/// * `ctx` - The instruction context.
Expand Down
192 changes: 192 additions & 0 deletions tests/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,40 @@ async function withdrawSplToken(
.rpc({ commitment: "processed" });
}

async function refundSplToken(
gatewayProgram: Program<Gateway>,
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());
Expand Down Expand Up @@ -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 () => {
Comment thread
s2imonovic marked this conversation as resolved.
Resolved
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 () => {
Comment thread
s2imonovic marked this conversation as resolved.
Resolved
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);
Expand Down
Loading