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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,4 @@ Audited by Cantina. See [audits/AUDIT_STATUS.md](audits/AUDIT_STATUS.md) for the
- **No `mod.rs` business logic**: only module declarations and re-exports.
- **PDA seeds co-located with state**: each state struct exposes its seed pattern; helpers live in `state/common.rs`.
- **Codama attributes drive IDL**: keep `#[codama(...)]` macros in sync with Rust types — `just generate-idl && git diff` catches drift.
- **Token-2022 extension allowlist**: rejects ConfidentialTransfer, NonTransferable, PermanentDelegate, TransferHook, TransferFee, MintCloseAuthority, Pausable.
- **Token-2022 extension policy**: rejects only mints with a configured `TransferHook` (either the hook `authority` or `program_id` is set). Inert `TransferHook` (both unset and therefore permanently immutable) is allowed.
7 changes: 7 additions & 0 deletions docs/001-program-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,3 +502,10 @@ Executes a transfer for a recurring delegation.
6. Update tracking
7. Execute transfer via SubscriptionAuthority
8. Emit `RecurringTransferEvent` via self-CPI

## Token-2022 Extension Policy

`Mint2022Account::check` rejects only mints with a configured `TransferHook`
(`authority` or `program_id` set). An inert `TransferHook` (both fields unset) is
allowed: with no hook authority, `update_transfer_hook` cannot be invoked, so the hook
stays permanently dormant.
1 change: 1 addition & 0 deletions program/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ pub enum SubscriptionsError {
ArithmeticUnderflow,
#[error("Invalid account discriminator")]
InvalidAccountDiscriminator,
/// Reserved for backwards compatibility.
#[error("Mint has ConfidentialTransfer extension")]
MintHasConfidentialTransfer,
#[error("Mint has NonTransferable extension")]
Expand Down
35 changes: 19 additions & 16 deletions program/src/instructions/helpers/token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,38 +27,42 @@ use crate::{
SubscriptionsError,
};

const EXTENSION_TYPE_CONFIDENTIAL_TRANSFER_MINT: u16 = 4;
const EXTENSION_TYPE_TRANSFER_HOOK: u16 = 14;

const TRANSFER_HOOK_EXTENSION_LEN: usize = 64;
const TLV_ENTRY_HEADER_LEN: usize = 4;
const TLV_EXTENSIONS_START: usize = 166;

/// Validates that a Token-2022 mint does not contain any blocked extensions.
///
/// Walks the TLV extension entries starting at byte 166 and rejects mints
/// that have ConfidentialTransfer or TransferHook extensions.
/// Validates that a Token-2022 mint does not contain any configured transfer hook.
fn validate_mint_extensions(data: &[u8]) -> Result<(), ProgramError> {
let mut offset = TLV_EXTENSIONS_START;

while offset + 4 <= data.len() {
while offset + TLV_ENTRY_HEADER_LEN <= data.len() {
let ext_type = u16::from_le_bytes([data[offset], data[offset + 1]]);
let ext_len = u16::from_le_bytes([data[offset + 2], data[offset + 3]]) as usize;
let ext_data_start = offset + TLV_ENTRY_HEADER_LEN;
let ext_data_end = ext_data_start
.checked_add(ext_len)
.ok_or::<ProgramError>(SubscriptionsError::InvalidToken2022MintAccountData.into())?;

// Type 0 = Uninitialized, signals end of TLV entries
if ext_type == 0 {
break;
}

match ext_type {
EXTENSION_TYPE_CONFIDENTIAL_TRANSFER_MINT => {
return Err(SubscriptionsError::MintHasConfidentialTransfer.into());
if ext_data_end > data.len() {
return Err(SubscriptionsError::InvalidToken2022MintAccountData.into());
}

if ext_type == EXTENSION_TYPE_TRANSFER_HOOK {
let extension_data = &data[ext_data_start..ext_data_end];
if extension_data.len() != TRANSFER_HOOK_EXTENSION_LEN {
return Err(SubscriptionsError::InvalidToken2022MintAccountData.into());
}
EXTENSION_TYPE_TRANSFER_HOOK => {
if extension_data.iter().any(|byte| *byte != 0) {
return Err(SubscriptionsError::MintHasTransferHook.into());
}
_ => {}
}

offset += 4 + ext_len;
offset = ext_data_end;
}

Ok(())
Expand Down Expand Up @@ -182,8 +186,7 @@ impl TokenInit for TokenAccount {
/// Validation for Token-2022 mint accounts.
///
/// Checks ownership by the Token-2022 program, minimum data length, the
/// `0x01` discriminator at byte 165, and rejects blocked extensions via
/// [`validate_mint_extensions`].
/// `0x01` discriminator at byte 165, and rejects configured transfer hooks.
pub struct Mint2022Account;

impl AccountCheck for Mint2022Account {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ use crate::{
pda::get_subscription_authority_pda,
utils::{
build_and_send_transaction, fetch_account, init_ata, init_aux_token_account, init_mint, init_wallet,
initialize_subscription_authority_action, initialize_subscription_authority_action_with_sponsor, setup,
initialize_subscription_authority_action, initialize_subscription_authority_action_with_sponsor,
set_transfer_hook_config, setup,
},
},
AccountDiscriminator, SubscriptionAuthority, SubscriptionsError,
Expand Down Expand Up @@ -109,7 +110,7 @@ fn initialize_subscription_authority_with_sponsor() {
#[case::no_extensions(&[], None)]
#[case::confidential_transfer(
&[ExtensionType::ConfidentialTransferMint],
Some(SubscriptionsError::MintHasConfidentialTransfer)
None
)]
#[case::non_transferable(
&[ExtensionType::NonTransferable],
Expand All @@ -123,9 +124,9 @@ fn initialize_subscription_authority_with_sponsor() {
&[ExtensionType::TransferFeeConfig],
None
)]
#[case::transfer_hook(
#[case::transfer_hook_unconfigured(
&[ExtensionType::TransferHook],
Some(SubscriptionsError::MintHasTransferHook)
None
)]
#[case::pausable(
&[ExtensionType::Pausable],
Expand All @@ -135,13 +136,13 @@ fn initialize_subscription_authority_with_sponsor() {
&[ExtensionType::MintCloseAuthority],
None
)]
#[case::multiple_blocked(
#[case::mixed_allowed(
&[ExtensionType::TransferFeeConfig, ExtensionType::TransferHook],
Some(SubscriptionsError::MintHasTransferHook)
None
)]
#[case::mixed_blocked(
#[case::mixed_allowed_confidential(
&[ExtensionType::MintCloseAuthority, ExtensionType::ConfidentialTransferMint],
Some(SubscriptionsError::MintHasConfidentialTransfer)
None
)]
fn initialize_subscription_authority_token_2022(
#[case] extensions: &[ExtensionType],
Expand Down Expand Up @@ -177,6 +178,42 @@ fn initialize_subscription_authority_token_2022(
}
}

#[test]
fn initialize_subscription_authority_rejects_transfer_hook_with_program_id() {
let (litesvm, user) = &mut setup();

let mint = init_mint(
litesvm,
TOKEN_2022_PROGRAM_ID,
MINT_DECIMALS,
1_000_000_000,
Some(user.pubkey()),
&[ExtensionType::TransferHook],
);
set_transfer_hook_config(litesvm, mint, None, Some(Pubkey::new_unique()));
init_ata(litesvm, mint, user.pubkey(), 1_000_000);

initialize_subscription_authority_action(litesvm, user, mint).0.assert_err(SubscriptionsError::MintHasTransferHook);
}

#[test]
fn initialize_subscription_authority_rejects_mutable_transfer_hook() {
let (litesvm, user) = &mut setup();

let mint = init_mint(
litesvm,
TOKEN_2022_PROGRAM_ID,
MINT_DECIMALS,
1_000_000_000,
Some(user.pubkey()),
&[ExtensionType::TransferHook],
);
set_transfer_hook_config(litesvm, mint, Some(user.pubkey()), None);
init_ata(litesvm, mint, user.pubkey(), 1_000_000);

initialize_subscription_authority_action(litesvm, user, mint).0.assert_err(SubscriptionsError::MintHasTransferHook);
}

#[test]
fn wrong_token_program_returns_error() {
let (litesvm, user) = &mut setup();
Expand Down
64 changes: 64 additions & 0 deletions tests/integration-tests/src/test_transfer_fixed_delegation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,70 @@ fn test_fixed_transfer_token_2022_transfer_fee() {
assert_eq!(remaining_amount, 40_000_000);
}

#[test]
fn test_fixed_transfer_token_2022_confidential_transfer_public_balance() {
let (mut litesvm, alice) = setup();
let bob = Keypair::new();
litesvm.airdrop(&bob.pubkey(), 10_000_000).unwrap();

let mint = init_mint(
&mut litesvm,
TOKEN_2022_PROGRAM_ID,
MINT_DECIMALS,
1_000_000_000,
Some(alice.pubkey()),
&[ExtensionType::ConfidentialTransferMint],
);
let alice_ata = init_ata(&mut litesvm, mint, alice.pubkey(), 100_000_000);
let bob_ata = init_ata(&mut litesvm, mint, bob.pubkey(), 0);

initialize_subscription_authority_action(&mut litesvm, &alice, mint).0.assert_ok();

let (res, delegation_pda) = CreateDelegation::new(&mut litesvm, &alice, mint, bob.pubkey())
.fixed(50_000_000, current_ts() + days(1) as i64);
res.assert_ok();

TransferDelegation::new(&mut litesvm, &bob, alice.pubkey(), mint, delegation_pda)
.amount(10_000_000)
.fixed()
.assert_ok();

assert_eq!(get_ata_balance(&litesvm, &alice_ata), 90_000_000);
assert_eq!(get_ata_balance(&litesvm, &bob_ata), 10_000_000);
}

#[test]
fn test_fixed_transfer_token_2022_unconfigured_transfer_hook() {
let (mut litesvm, alice) = setup();
let bob = Keypair::new();
litesvm.airdrop(&bob.pubkey(), 10_000_000).unwrap();

let mint = init_mint(
&mut litesvm,
TOKEN_2022_PROGRAM_ID,
MINT_DECIMALS,
1_000_000_000,
Some(alice.pubkey()),
&[ExtensionType::TransferHook],
);
let alice_ata = init_ata(&mut litesvm, mint, alice.pubkey(), 100_000_000);
let bob_ata = init_ata(&mut litesvm, mint, bob.pubkey(), 0);

initialize_subscription_authority_action(&mut litesvm, &alice, mint).0.assert_ok();

let (res, delegation_pda) = CreateDelegation::new(&mut litesvm, &alice, mint, bob.pubkey())
.fixed(50_000_000, current_ts() + days(1) as i64);
res.assert_ok();

TransferDelegation::new(&mut litesvm, &bob, alice.pubkey(), mint, delegation_pda)
.amount(10_000_000)
.fixed()
.assert_ok();

assert_eq!(get_ata_balance(&litesvm, &alice_ata), 90_000_000);
assert_eq!(get_ata_balance(&litesvm, &bob_ata), 10_000_000);
}

#[test]
fn test_fixed_transfer_multiple_times() {
let amount: u64 = 50_000_000;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::{
state::{header::VERSION_OFFSET, RecurringDelegation},
tests::{
asserts::TransactionResultExt,
constants::{MINT_DECIMALS, PROGRAM_ID, TOKEN_PROGRAM_ID},
constants::{MINT_DECIMALS, PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID},
idl,
pda::get_subscription_authority_pda,
utils::{
Expand All @@ -22,6 +22,7 @@ use solana_pubkey::Pubkey;
use solana_signer::Signer;
use solana_transaction_error::TransactionError::InstructionError;
use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id;
use spl_token_2022_interface::extension::ExtensionType;
use spl_token_interface::instruction::TokenInstruction::{Approve, Revoke};

fn setup_recurring_delegation(
Expand Down Expand Up @@ -795,3 +796,75 @@ fn test_recurring_transfer_past_drift_window() {
.recurring();
result.assert_err(SubscriptionsError::DelegationExpired);
}

#[test]
fn test_recurring_transfer_token_2022_confidential_transfer_public_balance() {
let (mut litesvm, alice) = setup();
let bob = Keypair::new();
litesvm.airdrop(&bob.pubkey(), 10_000_000).unwrap();

let mint = init_mint(
&mut litesvm,
TOKEN_2022_PROGRAM_ID,
MINT_DECIMALS,
1_000_000_000,
Some(alice.pubkey()),
&[ExtensionType::ConfidentialTransferMint],
);
let alice_ata = init_ata(&mut litesvm, mint, alice.pubkey(), 100_000_000);
let bob_ata = init_ata(&mut litesvm, mint, bob.pubkey(), 0);

initialize_subscription_authority_action(&mut litesvm, &alice, mint).0.assert_ok();

let (res, delegation_pda) = CreateDelegation::new(&mut litesvm, &alice, mint, bob.pubkey()).recurring(
50_000_000,
hours(1),
current_ts(),
current_ts() + days(1) as i64,
);
res.assert_ok();

TransferDelegation::new(&mut litesvm, &bob, alice.pubkey(), mint, delegation_pda)
.amount(10_000_000)
.recurring()
.assert_ok();

assert_eq!(get_ata_balance(&litesvm, &alice_ata), 90_000_000);
assert_eq!(get_ata_balance(&litesvm, &bob_ata), 10_000_000);
}

#[test]
fn test_recurring_transfer_token_2022_unconfigured_transfer_hook() {
let (mut litesvm, alice) = setup();
let bob = Keypair::new();
litesvm.airdrop(&bob.pubkey(), 10_000_000).unwrap();

let mint = init_mint(
&mut litesvm,
TOKEN_2022_PROGRAM_ID,
MINT_DECIMALS,
1_000_000_000,
Some(alice.pubkey()),
&[ExtensionType::TransferHook],
);
let alice_ata = init_ata(&mut litesvm, mint, alice.pubkey(), 100_000_000);
let bob_ata = init_ata(&mut litesvm, mint, bob.pubkey(), 0);

initialize_subscription_authority_action(&mut litesvm, &alice, mint).0.assert_ok();

let (res, delegation_pda) = CreateDelegation::new(&mut litesvm, &alice, mint, bob.pubkey()).recurring(
50_000_000,
hours(1),
current_ts(),
current_ts() + days(1) as i64,
);
res.assert_ok();

TransferDelegation::new(&mut litesvm, &bob, alice.pubkey(), mint, delegation_pda)
.amount(10_000_000)
.recurring()
.assert_ok();

assert_eq!(get_ata_balance(&litesvm, &alice_ata), 90_000_000);
assert_eq!(get_ata_balance(&litesvm, &bob_ata), 10_000_000);
}
16 changes: 16 additions & 0 deletions tests/integration-tests/src/utils/test_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,22 @@ pub fn init_mint(
mint
}

pub fn set_transfer_hook_config(
litesvm: &mut LiteSVM,
mint: Pubkey,
authority: Option<Pubkey>,
program_id: Option<Pubkey>,
) {
let mut account = litesvm.get_account(&mint).unwrap();
{
let mut state = StateWithExtensionsMut::<Mint2022>::unpack(&mut account.data).unwrap();
let extension = state.get_extension_mut::<TransferHook>().unwrap();
extension.authority = authority.try_into().unwrap();
extension.program_id = program_id.try_into().unwrap();
}
litesvm.set_account(mint, account).unwrap();
}

pub fn init_ata(litesvm: &mut LiteSVM, mint: Pubkey, owner: Pubkey, amount: u64) -> Pubkey {
let token_program = litesvm.get_account(&mint).unwrap().owner;
let ata = get_associated_token_address_with_program_id(&owner, &mint, &token_program);
Expand Down
Loading