Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ spl-discriminator = "0.5.1"

[dev-dependencies]
litesvm = "0.11.0"
solana-account = "3.2.0"
solana-instruction = "3.0.0"
solana-keypair = "3.0.1"
solana-message = "3.1.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub mod init_config;
pub mod init_mint;
pub mod init_wallet;
pub mod remove_wallet;
pub mod resize_meta_list;
pub mod tx_hook;

pub use attach_to_mint::*;
Expand All @@ -12,4 +13,5 @@ pub use init_config::*;
pub use init_mint::*;
pub use init_wallet::*;
pub use remove_wallet::*;
pub use resize_meta_list::*;
pub use tx_hook::*;
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
use anchor_lang::{
prelude::*, solana_program::program::invoke, solana_program::system_instruction::transfer,
};
use anchor_spl::{
token_2022::Token2022,
token_interface::{transfer_hook_update, Mint, TransferHookUpdate},
};

use spl_tlv_account_resolution::state::ExtraAccountMetaList;
use spl_transfer_hook_interface::instruction::ExecuteInstruction;

use crate::{get_extra_account_metas, get_meta_list_size, META_LIST_ACCOUNT_SEED};

/// Rewrites an existing mint's extra-metas account to the current
/// `get_extra_account_metas()` layout, reallocating it if the size changed.
///
/// This exists because `extra_metas_account` is a fixed-size PDA created once
/// by `init_mint`/`attach_to_mint`: if the program's extra-account list is
/// ever extended (e.g. to add the source-wallet check), mints that were set
/// up under the old layout are left with a stale, undersized account and
/// their transfers start failing the hook's account-count check. Any mint
/// authority can call this to bring an existing mint's extra-metas account
/// back in sync after such an upgrade.
#[derive(Accounts)]
pub struct ResizeMetaList<'info> {
#[account(mut)]
pub payer: Signer<'info>,

#[account(mut, mint::token_program = token_program)]
pub mint: Box<InterfaceAccount<'info, Mint>>,

#[account(
mut,
seeds = [META_LIST_ACCOUNT_SEED, mint.key().as_ref()],
bump,
)]
/// CHECK: extra metas account
pub extra_metas_account: UncheckedAccount<'info>,

pub system_program: Program<'info, System>,

pub token_program: Program<'info, Token2022>,
}

impl ResizeMetaList<'_> {
pub fn resize_meta_list(&mut self) -> Result<()> {
// Re-setting the transfer hook to itself has no effect on the mint,
// but the CPI only succeeds if `payer` is the mint's current
// transfer-hook authority - the same check `attach_to_mint` relies
// on, reused here so this instruction can't be called by anyone
// other than whoever is already trusted to configure this mint's hook.
let tx_hook_accs = TransferHookUpdate {
token_program_id: self.token_program.to_account_info(),
mint: self.mint.to_account_info(),
authority: self.payer.to_account_info(),
};
let ctx = CpiContext::new(self.token_program.key(), tx_hook_accs);
transfer_hook_update(ctx, Some(crate::ID_CONST))?;

let account_info = self.extra_metas_account.to_account_info();
let new_size = get_meta_list_size()?;

let min_balance = Rent::get()?.minimum_balance(new_size);
if min_balance > account_info.lamports() {
invoke(
&transfer(&self.payer.key(), account_info.key, min_balance - account_info.lamports()),
&[self.payer.to_account_info(), account_info.clone(), self.system_program.to_account_info()],
)?;
}
account_info.resize(new_size)?;

let metas = get_extra_account_metas()?;
let mut data = account_info.try_borrow_mut_data()?;
ExtraAccountMetaList::update::<ExecuteInstruction>(&mut data, &metas)
.map_err(|_| ProgramError::InvalidAccountData)?;

Ok(())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ pub struct TxHook<'info> {
/// CHECK:
pub meta_list: UncheckedAccount<'info>,
/// CHECK:
pub ab_wallet: UncheckedAccount<'info>,
pub source_ab_wallet: UncheckedAccount<'info>,
/// CHECK:
pub destination_ab_wallet: UncheckedAccount<'info>,
}

impl TxHook<'_> {
Expand All @@ -35,31 +37,18 @@ impl TxHook<'_> {

let metadata = mint.get_variable_len_extension::<TokenMetadata>()?;
let decoded_mode = Self::decode_metadata(&metadata)?;
let decoded_wallet_mode = self.decode_wallet_mode()?;

match (decoded_mode, decoded_wallet_mode) {
// first check the force allow modes
(DecodedMintMode::Allow, DecodedWalletMode::Allow) => Ok(()),
(DecodedMintMode::Allow, _) => Err(ABListError::WalletNotAllowed.into()),
// then check if the wallet is blocked
(_, DecodedWalletMode::Block) => Err(ABListError::WalletBlocked.into()),
(DecodedMintMode::Block, _) => Ok(()),
// lastly check the threshold mode
(DecodedMintMode::Threshold(threshold), DecodedWalletMode::None)
if amount >= threshold =>
{
Err(ABListError::AmountNotAllowed.into())
}
(DecodedMintMode::Threshold(_), _) => Ok(()),
}
let source_wallet_mode = Self::decode_wallet_mode(&self.source_ab_wallet)?;
let destination_wallet_mode = Self::decode_wallet_mode(&self.destination_ab_wallet)?;

decide(decoded_mode, source_wallet_mode, destination_wallet_mode, amount)
}

fn decode_wallet_mode(&self) -> Result<DecodedWalletMode> {
if self.ab_wallet.data_is_empty() {
fn decode_wallet_mode(account: &UncheckedAccount) -> Result<DecodedWalletMode> {
if account.data_is_empty() {
return Ok(DecodedWalletMode::None);
}

let wallet_data = &mut self.ab_wallet.data.borrow();
let wallet_data = &mut account.data.borrow();
let wallet = ABWallet::try_deserialize(&mut &wallet_data[..])?;

if wallet.allowed {
Expand Down Expand Up @@ -106,14 +95,132 @@ impl TxHook<'_> {
}
}

/// The transfer decision, kept as a pure function of the decoded mint/wallet
/// state so it's directly unit-testable without needing real accounts.
///
/// A wallet with an explicit `allowed: false` ABWallet record is blocked from
/// transacting entirely - neither sending nor receiving - regardless of the
/// mint's overall mode. This is checked first and applies to both sides.
///
/// Beyond that, Allow/Threshold mode gate who may *receive* only, matching
/// this program's documented semantics (see README): Force Allow requires
/// the receiver to be explicitly allowed in; Threshold requires the receiver
/// to be explicitly allowed in for transfers at or above the threshold.
fn decide(
mint_mode: DecodedMintMode,
source_wallet_mode: DecodedWalletMode,
destination_wallet_mode: DecodedWalletMode,
amount: u64,
) -> Result<()> {
if source_wallet_mode == DecodedWalletMode::Block || destination_wallet_mode == DecodedWalletMode::Block {
return Err(ABListError::WalletBlocked.into());
}

match (mint_mode, destination_wallet_mode) {
// first check the force allow modes
(DecodedMintMode::Allow, DecodedWalletMode::Allow) => Ok(()),
(DecodedMintMode::Allow, _) => Err(ABListError::WalletNotAllowed.into()),
// block mode: neither wallet was explicitly blocked (checked above), so allow
(DecodedMintMode::Block, _) => Ok(()),
// lastly check the threshold mode
(DecodedMintMode::Threshold(threshold), DecodedWalletMode::None) if amount >= threshold => {
Err(ABListError::AmountNotAllowed.into())
}
(DecodedMintMode::Threshold(_), _) => Ok(()),
}
}

#[derive(Debug, PartialEq)]
enum DecodedMintMode {
Allow,
Block,
Threshold(u64),
}

#[derive(Debug, PartialEq)]
enum DecodedWalletMode {
Allow,
Block,
None,
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn source_blocked_is_always_rejected() {
// This is the exact case that was broken: a blocked SENDER used to
// be allowed through, since only the destination was ever checked.
for mint_mode in [DecodedMintMode::Allow, DecodedMintMode::Block, DecodedMintMode::Threshold(100)] {
for destination_mode in [DecodedWalletMode::Allow, DecodedWalletMode::Block, DecodedWalletMode::None] {
let result = decide(mint_mode_clone(&mint_mode), DecodedWalletMode::Block, destination_mode, 0);
assert!(
result.is_err(),
"expected a blocked source to be rejected regardless of mint mode / destination status"
);
}
}
}

#[test]
fn destination_blocked_is_always_rejected() {
// Regression guard: this already worked before the fix, must keep working.
for mint_mode in [DecodedMintMode::Allow, DecodedMintMode::Block, DecodedMintMode::Threshold(100)] {
for source_mode in [DecodedWalletMode::Allow, DecodedWalletMode::Block, DecodedWalletMode::None] {
let result = decide(mint_mode_clone(&mint_mode), source_mode, DecodedWalletMode::Block, 0);
assert!(
result.is_err(),
"expected a blocked destination to be rejected regardless of mint mode / source status"
);
}
}
}

#[test]
fn allow_mode_does_not_gate_the_source() {
// The source is intentionally NOT gated in Allow mode - only "who may
// receive" is documented/intended to be restricted. This is the
// control case proving the fix doesn't over-correct.
let result = decide(DecodedMintMode::Allow, DecodedWalletMode::None, DecodedWalletMode::Allow, 0);
assert!(result.is_ok());
}

#[test]
fn allow_mode_rejects_an_unlisted_destination() {
let result = decide(DecodedMintMode::Allow, DecodedWalletMode::None, DecodedWalletMode::None, 0);
assert!(result.is_err());
}

#[test]
fn block_mode_allows_unlisted_wallets() {
let result = decide(DecodedMintMode::Block, DecodedWalletMode::None, DecodedWalletMode::None, 0);
assert!(result.is_ok());
}

#[test]
fn threshold_mode_allows_small_transfers_to_unlisted_destinations() {
let result = decide(DecodedMintMode::Threshold(100), DecodedWalletMode::None, DecodedWalletMode::None, 50);
assert!(result.is_ok());
}

#[test]
fn threshold_mode_rejects_large_transfers_to_unlisted_destinations() {
let result = decide(DecodedMintMode::Threshold(100), DecodedWalletMode::None, DecodedWalletMode::None, 100);
assert!(result.is_err());
}

#[test]
fn threshold_mode_allows_large_transfers_to_an_allowed_destination() {
let result = decide(DecodedMintMode::Threshold(100), DecodedWalletMode::None, DecodedWalletMode::Allow, 100);
assert!(result.is_ok());
}

fn mint_mode_clone(mode: &DecodedMintMode) -> DecodedMintMode {
match mode {
DecodedMintMode::Allow => DecodedMintMode::Allow,
DecodedMintMode::Block => DecodedMintMode::Block,
DecodedMintMode::Threshold(t) => DecodedMintMode::Threshold(*t),
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,8 @@ pub mod abl_token {
pub fn change_mode(ctx: Context<ChangeMode>, args: ChangeModeArgs) -> Result<()> {
ctx.accounts.change_mode(args)
}

pub fn resize_meta_list(ctx: Context<ResizeMetaList>) -> Result<()> {
ctx.accounts.resize_meta_list()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,27 @@ use spl_tlv_account_resolution::{
use crate::AB_WALLET_SEED;

pub fn get_meta_list_size() -> Result<usize> {
Ok(ExtraAccountMetaList::size_of(1).map_err(|_| ProgramError::InvalidArgument)?)
Ok(ExtraAccountMetaList::size_of(2).map_err(|_| ProgramError::InvalidArgument)?)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Existing metadata lists become incompatible

If any mints retain metadata-list accounts initialized before this upgrade, those accounts still resolve only the destination wallet while the upgraded hook requires both wallet accounts, causing all transfers for those mints to fail. Both setup paths use init, and there is no instruction to resize and rewrite an existing list.

Knowledge Base Used: Tokens Directory Overview

}

pub fn get_extra_account_metas() -> Result<Vec<ExtraAccountMeta>> {
Ok(vec![
// [5] ab_wallet for destination token account wallet
// [5] ab_wallet for source token account wallet
ExtraAccountMeta::new_with_seeds(
&[
Seed::Literal {
bytes: AB_WALLET_SEED.to_vec(),
},
Seed::AccountData {
account_index: 0,
data_index: 32,
length: 32,
},
],
false,
false,
).map_err(|_| ProgramError::InvalidArgument)?, // [0] source token account
// [6] ab_wallet for destination token account wallet
ExtraAccountMeta::new_with_seeds(
&[
Seed::Literal {
Expand Down
Loading
Loading