Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -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() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

dont think the ci is running those test, might need

cargo test -p abl-token

// 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 @@ -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)?)
Comment thread
dev-jodee marked this conversation as resolved.
Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

can we have a const for that magic number

}

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
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,17 @@ export function useSendTokens() {
if (!transferHook) throw new Error('bad token');
const extraMetas = getExtraAccountMetaAddress(mint, transferHook.programId);

const seeds = [Buffer.from('ab_wallet'), destination.toBuffer()];
const abWallet = PublicKey.findProgramAddressSync(seeds, transferHook.programId)[0];
// The hook checks both the sender and the receiver's allow/block
// status - the account order here must match the program's
// get_extra_account_metas() (source first, then destination).
const sourceSeeds = [Buffer.from('ab_wallet'), publicKey.toBuffer()];
const sourceAbWallet = PublicKey.findProgramAddressSync(sourceSeeds, transferHook.programId)[0];

ix3.keys.push({ pubkey: abWallet, isSigner: false, isWritable: false });
const destinationSeeds = [Buffer.from('ab_wallet'), destination.toBuffer()];
const destinationAbWallet = PublicKey.findProgramAddressSync(destinationSeeds, transferHook.programId)[0];

ix3.keys.push({ pubkey: sourceAbWallet, isSigner: false, isWritable: false });
ix3.keys.push({ pubkey: destinationAbWallet, isSigner: false, isWritable: false });
ix3.keys.push({
pubkey: transferHook.programId,
isSigner: false,
Expand Down
Loading