Skip to main content

hopper_runtime/
token_2022_ext.rs

1//! Zero-copy Token-2022 extension TLV readers.
2//!
3//! Token-2022 stores extension data in a TLV region after a fixed
4//! per-account prefix. Each TLV entry is
5//! `[type: u16 LE][length: u16 LE][data: length bytes]`.
6//!
7//! This module reads Token-2022 TLV data directly from account bytes,
8//! avoiding full-account decode while preserving the on-chain wire
9//! rules.
10//!
11//! Every reader here validates only the bytes it reads. No heap
12//! allocation, no full-account decode, no version coupling to
13//! `spl-token-2022`. A program that needs to enforce
14//! `transfer_hook::authority = X` on a mint calls [`require_transfer_hook_authority`]
15//! and pays the cost of a TLV scan plus a 32-byte compare. That is the
16//! cost floor for a correct check.
17//!
18//! ## On-chain layout (authoritative)
19//!
20//! An extended mint is padded to the same length as an extended token
21//! account so that the Token-2022 program cannot confuse the two by
22//! data length alone; the one-byte `AccountType` discriminator at
23//! offset `ACCOUNT_TYPE_OFFSET` (= 165) disambiguates them.
24//!
25//! ```text
26//! Extended mint         : [0..82] Mint base | [82..165] padding | [165] AccountType=1 | [166..] TLV
27//! Extended token account: [0..165] Account base                 | [165] AccountType=2 | [166..] TLV
28//! ```
29//!
30//! Both shapes place the AccountType byte at offset 165 and begin TLV
31//! data at offset 166. `BASE_MINT_LEN` (82) is the length of a *plain*,
32//! non-extended mint and is used by length checks; it is **not** the
33//! offset at which mint extensions live. This layout matches
34//! `spl-token-2022` (`validate_account_type` keys on
35//! `bytes[BASE_ACCOUNT_LENGTH]` where `BASE_ACCOUNT_LENGTH = 165`).
36//!
37//! ## Extension type constants
38//!
39//! Values are the on-chain `u16` encoding from
40//! `spl-token-2022::extension::ExtensionType`. They are stable wire
41//! values and safe to hard-code. The full set is listed below so
42//! tooling can surface the name for any TLV it encounters.
43//!
44//! ## Authority comparisons and `OptionalNonZeroPubkey`
45//!
46//! Token-2022 stores optional authorities as `OptionalNonZeroPubkey`:
47//! an **all-zero pubkey means "not set"**. The `require_*_authority`
48//! comparators below do a plain 32-byte compare, so passing an all-zero
49//! `expected` would "match" an unset authority. Never pass
50//! `Address::default()` as the expected authority; if the intent is
51//! "authority must be unset", compare against zero explicitly and name
52//! that intent in the calling code.
53
54use crate::{account::AccountView, address::Address, error::ProgramError, result::ProgramResult};
55
56// ---------------------------------------------------------------------
57
58pub const EXT_UNINITIALIZED: u16 = 0;
59pub const EXT_TRANSFER_FEE_CONFIG: u16 = 1;
60pub const EXT_TRANSFER_FEE_AMOUNT: u16 = 2;
61pub const EXT_MINT_CLOSE_AUTHORITY: u16 = 3;
62pub const EXT_CONFIDENTIAL_TRANSFER_MINT: u16 = 4;
63pub const EXT_CONFIDENTIAL_TRANSFER_ACCOUNT: u16 = 5;
64pub const EXT_DEFAULT_ACCOUNT_STATE: u16 = 6;
65pub const EXT_IMMUTABLE_OWNER: u16 = 7;
66pub const EXT_MEMO_TRANSFER: u16 = 8;
67pub const EXT_NON_TRANSFERABLE: u16 = 9;
68pub const EXT_INTEREST_BEARING_CONFIG: u16 = 10;
69pub const EXT_CPI_GUARD: u16 = 11;
70pub const EXT_PERMANENT_DELEGATE: u16 = 12;
71pub const EXT_NON_TRANSFERABLE_ACCOUNT: u16 = 13;
72pub const EXT_TRANSFER_HOOK: u16 = 14;
73pub const EXT_TRANSFER_HOOK_ACCOUNT: u16 = 15;
74pub const EXT_CONFIDENTIAL_TRANSFER_FEE_CONFIG: u16 = 16;
75pub const EXT_CONFIDENTIAL_TRANSFER_FEE_AMOUNT: u16 = 17;
76pub const EXT_METADATA_POINTER: u16 = 18;
77pub const EXT_TOKEN_METADATA: u16 = 19;
78pub const EXT_GROUP_POINTER: u16 = 20;
79pub const EXT_TOKEN_GROUP: u16 = 21;
80pub const EXT_GROUP_MEMBER_POINTER: u16 = 22;
81pub const EXT_TOKEN_GROUP_MEMBER: u16 = 23;
82pub const EXT_CONFIDENTIAL_MINT_BURN: u16 = 24;
83pub const EXT_SCALED_UI_AMOUNT_CONFIG: u16 = 25;
84pub const EXT_PAUSABLE_CONFIG: u16 = 26;
85pub const EXT_PAUSABLE_ACCOUNT: u16 = 27;
86pub const EXT_PERMISSIONED_BURN: u16 = 28;
87
88/// Highest Token-2022 extension discriminator understood by this release.
89///
90/// Allowlist policies deliberately reject larger values. A newly deployed
91/// extension must be reviewed and added explicitly instead of silently
92/// inheriting assumptions written for an older Token-2022 program.
93pub const MAX_KNOWN_EXTENSION_TYPE: u16 = EXT_PERMISSIONED_BURN;
94
95/// Account-type byte: Mint.
96pub const ACCOUNT_TYPE_MINT: u8 = 0x01;
97/// Account-type byte: Token Account.
98pub const ACCOUNT_TYPE_TOKEN: u8 = 0x02;
99
100/// Length of a plain (non-extended) mint's data region.
101///
102/// This is the stride of the `Mint` base struct; it is **not** the
103/// offset at which mint extensions begin (see [`TLV_OFFSET`]). Used
104/// for the length-only check that distinguishes a legacy SPL mint
105/// (exactly 82 bytes) from an extended Token-2022 mint.
106pub const BASE_MINT_LEN: usize = 82;
107/// Length of a plain (non-extended) token-account's data region.
108///
109/// Also equal to [`ACCOUNT_TYPE_OFFSET`]: an extended mint is padded
110/// up to this length so that the AccountType discriminator sits at
111/// the same offset as on an extended token account.
112pub const BASE_TOKEN_LEN: usize = 165;
113/// Canonical SPL Token / Token-2022 multisig account length.
114///
115/// Token-2022's allocator deliberately pads extensible state away from this
116/// exact size, so it is never a valid mint or token-account TLV envelope.
117pub const TOKEN_MULTISIG_LEN: usize = 355;
118/// Offset of the `AccountType` discriminator on any extended
119/// Token-2022 account (mint or token account).
120pub const ACCOUNT_TYPE_OFFSET: usize = BASE_TOKEN_LEN;
121/// Offset at which the TLV extension region begins on any extended
122/// Token-2022 account (mint or token account).
123pub const TLV_OFFSET: usize = ACCOUNT_TYPE_OFFSET + 1;
124/// Start of the mint's extension padding region (82..165). Bytes in
125/// this range are zero-filled and exist purely to equalize the length
126/// of extended mints and extended token accounts.
127pub const MINT_EXTENSION_PADDING_START: usize = BASE_MINT_LEN;
128/// End of the mint extension padding region (exclusive).
129pub const MINT_EXTENSION_PADDING_END: usize = ACCOUNT_TYPE_OFFSET;
130
131// ---------------------------------------------------------------------
132
133/// Locate an extension in a Token-2022 account's TLV region.
134///
135/// Returns the slice of the extension's data bytes (not including the
136/// 4-byte TLV header) or `None` if the extension is not present.
137///
138/// `tlv_bytes` must be the account data starting at the TLV region
139/// (i.e. `&data[TLV_OFFSET..]` for both mints and token accounts).
140/// Use [`mint_tlv_region`] or [`token_account_tlv_region`] to obtain
141/// this slice safely. Malformed TLVs (length runs past the buffer)
142/// return `None` rather than panic.
143///
144/// One pass, O(n) in the TLV count. No allocation. The caller is
145/// expected to amortize calls by grouping checks.
146#[inline]
147pub fn find_extension(tlv_bytes: &[u8], ext_type: u16) -> Option<&[u8]> {
148    let mut cursor = 0usize;
149    while cursor + 4 <= tlv_bytes.len() {
150        let t = u16::from_le_bytes([tlv_bytes[cursor], tlv_bytes[cursor + 1]]);
151        let len = u16::from_le_bytes([tlv_bytes[cursor + 2], tlv_bytes[cursor + 3]]) as usize;
152        let data_start = cursor + 4;
153        let data_end = data_start + len;
154        if data_end > tlv_bytes.len() {
155            return None;
156        }
157        if t == ext_type {
158            return Some(&tlv_bytes[data_start..data_end]);
159        }
160        if t == EXT_UNINITIALIZED {
161            // Uninitialized marker with zero length is a valid
162            // stopping condition. Anything else with type 0 is
163            // malformed padding; we stop scanning to avoid running
164            // off the end through stray bytes.
165            return None;
166        }
167        cursor = data_end;
168    }
169    None
170}
171
172/// Validate a TLV stream without allocating or interpreting extension data.
173///
174/// Unlike [`find_extension`], this function distinguishes a missing extension
175/// from malformed bytes. Unknown non-zero type values remain structurally
176/// valid here so generic readers stay forward-compatible. Security policies
177/// that must fail closed on new extension semantics should use
178/// [`validate_extension_allowlist`].
179pub fn validate_tlv_structure(tlv_bytes: &[u8]) -> ProgramResult {
180    let mut cursor = 0usize;
181    while cursor < tlv_bytes.len() {
182        // Token-2022 permits one trailing allocation byte during realloc.
183        if tlv_bytes.len() - cursor < 2 {
184            return Ok(());
185        }
186
187        let ext_type = u16::from_le_bytes([tlv_bytes[cursor], tlv_bytes[cursor + 1]]);
188        if ext_type == EXT_UNINITIALIZED {
189            return Ok(());
190        }
191        if tlv_bytes.len() - cursor < 4 {
192            return Err(ProgramError::InvalidAccountData);
193        }
194
195        let len = u16::from_le_bytes([tlv_bytes[cursor + 2], tlv_bytes[cursor + 3]]) as usize;
196        cursor = cursor
197            .checked_add(4)
198            .and_then(|start| start.checked_add(len))
199            .ok_or(ProgramError::InvalidAccountData)?;
200        if cursor > tlv_bytes.len() {
201            return Err(ProgramError::InvalidAccountData);
202        }
203    }
204    Ok(())
205}
206
207/// Require every initialized TLV entry to be known, unique, and allowlisted.
208///
209/// This is the fail-closed counterpart to the presence readers. It is suited
210/// to custody and settlement code where an unknown future extension must not
211/// be treated as harmless merely because an older denylist did not name it.
212pub fn validate_extension_allowlist(tlv_bytes: &[u8], allowed: &[u16]) -> ProgramResult {
213    let mut cursor = 0usize;
214    let mut seen = 0u32;
215    while cursor < tlv_bytes.len() {
216        if tlv_bytes.len() - cursor < 2 {
217            return Ok(());
218        }
219
220        let ext_type = u16::from_le_bytes([tlv_bytes[cursor], tlv_bytes[cursor + 1]]);
221        if ext_type == EXT_UNINITIALIZED {
222            return Ok(());
223        }
224        if ext_type > MAX_KNOWN_EXTENSION_TYPE || !allowed.contains(&ext_type) {
225            return Err(ProgramError::InvalidAccountData);
226        }
227        let bit = 1u32
228            .checked_shl(ext_type as u32)
229            .ok_or(ProgramError::InvalidAccountData)?;
230        if seen & bit != 0 {
231            return Err(ProgramError::InvalidAccountData);
232        }
233        seen |= bit;
234
235        if tlv_bytes.len() - cursor < 4 {
236            return Err(ProgramError::InvalidAccountData);
237        }
238        let len = u16::from_le_bytes([tlv_bytes[cursor + 2], tlv_bytes[cursor + 3]]) as usize;
239        cursor = cursor
240            .checked_add(4)
241            .and_then(|start| start.checked_add(len))
242            .ok_or(ProgramError::InvalidAccountData)?;
243        if cursor > tlv_bytes.len() {
244            return Err(ProgramError::InvalidAccountData);
245        }
246    }
247    Ok(())
248}
249
250/// Slice the TLV region out of a mint account's data.
251///
252/// Returns `None` if the account is too short to be an *extended*
253/// Token-2022 mint (length must be strictly greater than
254/// [`TLV_OFFSET`]; a plain 82-byte legacy mint has no TLV region).
255///
256/// Performs four validations:
257/// 1. Account length is large enough to contain at least the TLV
258///    header offset (`> TLV_OFFSET`, i.e. >= 166).
259/// 2. The length is not the canonical 355-byte multisig shape.
260/// 3. The `AccountType` discriminator at [`ACCOUNT_TYPE_OFFSET`]
261///    is either [`ACCOUNT_TYPE_MINT`] (0x01) or `0x00`. We accept
262///    `0x00` for a just-reallocated mint before the Token-2022 program
263///    stamps its account type; every subsequent extension initializer
264///    writes the correct byte, and the TLV scanner tolerates an
265///    all-zero region by hitting `EXT_UNINITIALIZED` on the first
266///    header read. This matches `spl-token-2022`'s permissive init
267///    sequencing.
268/// 4. The mint equalization padding is zero, then the tail slice begins at
269///    [`TLV_OFFSET`] (166).
270///
271/// The bytes in `[BASE_MINT_LEN..ACCOUNT_TYPE_OFFSET]` (82..165) are
272/// Token-2022's equalization padding. They are not part of the TLV stream, but
273/// canonical unpacking requires them to remain zero.
274#[inline]
275pub fn mint_tlv_region(data: &[u8]) -> Option<&[u8]> {
276    if data.len() <= TLV_OFFSET || data.len() == TOKEN_MULTISIG_LEN {
277        return None;
278    }
279    let kind = data[ACCOUNT_TYPE_OFFSET];
280    if kind != ACCOUNT_TYPE_MINT && kind != 0 {
281        return None;
282    }
283    if data[MINT_EXTENSION_PADDING_START..MINT_EXTENSION_PADDING_END]
284        .iter()
285        .any(|byte| *byte != 0)
286    {
287        return None;
288    }
289    Some(&data[TLV_OFFSET..])
290}
291
292/// Slice the TLV region out of a token-account's data.
293///
294/// Returns `None` if the account is too short to be an *extended*
295/// Token-2022 token account. Same validation shape as
296/// [`mint_tlv_region`] but requires the discriminator at
297/// [`ACCOUNT_TYPE_OFFSET`] be [`ACCOUNT_TYPE_TOKEN`] (0x02) or
298/// `0x00`. TLV data is read from [`TLV_OFFSET`] (166).
299#[inline]
300pub fn token_account_tlv_region(data: &[u8]) -> Option<&[u8]> {
301    if data.len() <= TLV_OFFSET || data.len() == TOKEN_MULTISIG_LEN {
302        return None;
303    }
304    let kind = data[ACCOUNT_TYPE_OFFSET];
305    if kind != ACCOUNT_TYPE_TOKEN && kind != 0 {
306        return None;
307    }
308    Some(&data[TLV_OFFSET..])
309}
310
311/// A no-alloc Token-2022 extension policy over a TLV region.
312///
313/// `required` entries must be present; `forbidden` entries must be absent.
314/// This is the common core beneath declarative account constraints and tiny
315/// on-chain probes that want to validate a Token-2022 shape without pulling in
316/// `spl-token-2022` deserialization.
317#[derive(Clone, Copy, Debug, PartialEq, Eq)]
318pub struct ExtensionPolicy<'a> {
319    pub required: &'a [u16],
320    pub forbidden: &'a [u16],
321}
322
323impl<'a> ExtensionPolicy<'a> {
324    #[inline]
325    pub const fn new(required: &'a [u16], forbidden: &'a [u16]) -> Self {
326        Self {
327            required,
328            forbidden,
329        }
330    }
331}
332
333#[inline]
334pub fn require_extension(tlv: &[u8], ext_type: u16) -> ProgramResult {
335    validate_tlv_structure(tlv)?;
336    if find_extension(tlv, ext_type).is_some() {
337        Ok(())
338    } else {
339        Err(ProgramError::InvalidAccountData)
340    }
341}
342
343#[inline]
344pub fn forbid_extension(tlv: &[u8], ext_type: u16) -> ProgramResult {
345    validate_tlv_structure(tlv)?;
346    if find_extension(tlv, ext_type).is_none() {
347        Ok(())
348    } else {
349        Err(ProgramError::InvalidAccountData)
350    }
351}
352
353#[inline]
354pub fn validate_extension_policy(tlv: &[u8], policy: &ExtensionPolicy<'_>) -> ProgramResult {
355    validate_tlv_structure(tlv)?;
356    for ext_type in policy.required {
357        if find_extension(tlv, *ext_type).is_none() {
358            return Err(ProgramError::InvalidAccountData);
359        }
360    }
361    for ext_type in policy.forbidden {
362        if find_extension(tlv, *ext_type).is_some() {
363            return Err(ProgramError::InvalidAccountData);
364        }
365    }
366    Ok(())
367}
368
369// ---------------------------------------------------------------------
370
371/// Require a mint to carry the `NonTransferable` extension.
372///
373/// Use when a program is designed to only ever mint soulbound tokens.
374#[inline]
375pub fn require_non_transferable(mint: &AccountView<'_>) -> ProgramResult {
376    let data = mint
377        .try_borrow()
378        .map_err(|_| ProgramError::AccountBorrowFailed)?;
379    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
380    if find_extension(tlv, EXT_NON_TRANSFERABLE).is_some() {
381        Ok(())
382    } else {
383        Err(ProgramError::InvalidAccountData)
384    }
385}
386
387/// Require a mint's `MintCloseAuthority` extension to equal `expected`.
388#[inline]
389pub fn require_mint_close_authority(mint: &AccountView<'_>, expected: &Address) -> ProgramResult {
390    let data = mint
391        .try_borrow()
392        .map_err(|_| ProgramError::AccountBorrowFailed)?;
393    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
394    let ext =
395        find_extension(tlv, EXT_MINT_CLOSE_AUTHORITY).ok_or(ProgramError::InvalidAccountData)?;
396    if ext.len() < 32 {
397        return Err(ProgramError::InvalidAccountData);
398    }
399    if crate::address::keys_eq_bytes(&ext[..32], expected.as_array()) {
400        Ok(())
401    } else {
402        Err(ProgramError::IncorrectAuthority)
403    }
404}
405
406/// Require a mint's `PermanentDelegate` extension to equal `expected`.
407#[inline]
408pub fn require_permanent_delegate(mint: &AccountView<'_>, expected: &Address) -> ProgramResult {
409    let data = mint
410        .try_borrow()
411        .map_err(|_| ProgramError::AccountBorrowFailed)?;
412    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
413    let ext =
414        find_extension(tlv, EXT_PERMANENT_DELEGATE).ok_or(ProgramError::InvalidAccountData)?;
415    if ext.len() < 32 {
416        return Err(ProgramError::InvalidAccountData);
417    }
418    if crate::address::keys_eq_bytes(&ext[..32], expected.as_array()) {
419        Ok(())
420    } else {
421        Err(ProgramError::IncorrectAuthority)
422    }
423}
424
425/// Require a mint's `TransferHook` program id to equal `expected`.
426///
427/// `TransferHook` layout: `[authority: 32][program_id: 32]`. This
428/// validates the second field. Use [`require_transfer_hook_authority`]
429/// for the first.
430#[inline]
431pub fn require_transfer_hook_program(mint: &AccountView<'_>, expected: &Address) -> ProgramResult {
432    let data = mint
433        .try_borrow()
434        .map_err(|_| ProgramError::AccountBorrowFailed)?;
435    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
436    let ext = find_extension(tlv, EXT_TRANSFER_HOOK).ok_or(ProgramError::InvalidAccountData)?;
437    if ext.len() < 64 {
438        return Err(ProgramError::InvalidAccountData);
439    }
440    if crate::address::keys_eq_bytes(&ext[32..64], expected.as_array()) {
441        Ok(())
442    } else {
443        Err(ProgramError::IncorrectProgramId)
444    }
445}
446
447/// Require a mint's `TransferHook` authority to equal `expected`.
448#[inline]
449pub fn require_transfer_hook_authority(
450    mint: &AccountView<'_>,
451    expected: &Address,
452) -> ProgramResult {
453    let data = mint
454        .try_borrow()
455        .map_err(|_| ProgramError::AccountBorrowFailed)?;
456    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
457    let ext = find_extension(tlv, EXT_TRANSFER_HOOK).ok_or(ProgramError::InvalidAccountData)?;
458    if ext.len() < 32 {
459        return Err(ProgramError::InvalidAccountData);
460    }
461    if crate::address::keys_eq_bytes(&ext[..32], expected.as_array()) {
462        Ok(())
463    } else {
464        Err(ProgramError::IncorrectAuthority)
465    }
466}
467
468/// Require a mint's `MetadataPointer` metadata-address to equal `expected`.
469///
470/// `MetadataPointer` layout: `[authority: 32][metadata_address: 32]`.
471#[inline]
472pub fn require_metadata_pointer_address(
473    mint: &AccountView<'_>,
474    expected: &Address,
475) -> ProgramResult {
476    let data = mint
477        .try_borrow()
478        .map_err(|_| ProgramError::AccountBorrowFailed)?;
479    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
480    let ext = find_extension(tlv, EXT_METADATA_POINTER).ok_or(ProgramError::InvalidAccountData)?;
481    if ext.len() < 64 {
482        return Err(ProgramError::InvalidAccountData);
483    }
484    if crate::address::keys_eq_bytes(&ext[32..64], expected.as_array()) {
485        Ok(())
486    } else {
487        Err(ProgramError::InvalidAccountData)
488    }
489}
490
491/// Require a mint's `MetadataPointer` authority to equal `expected`.
492#[inline]
493pub fn require_metadata_pointer_authority(
494    mint: &AccountView<'_>,
495    expected: &Address,
496) -> ProgramResult {
497    let data = mint
498        .try_borrow()
499        .map_err(|_| ProgramError::AccountBorrowFailed)?;
500    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
501    let ext = find_extension(tlv, EXT_METADATA_POINTER).ok_or(ProgramError::InvalidAccountData)?;
502    if ext.len() < 32 {
503        return Err(ProgramError::InvalidAccountData);
504    }
505    if crate::address::keys_eq_bytes(&ext[..32], expected.as_array()) {
506        Ok(())
507    } else {
508        Err(ProgramError::IncorrectAuthority)
509    }
510}
511
512/// Require a token account to carry the `ImmutableOwner` extension.
513#[inline]
514pub fn require_immutable_owner(token_account: &AccountView<'_>) -> ProgramResult {
515    let data = token_account
516        .try_borrow()
517        .map_err(|_| ProgramError::AccountBorrowFailed)?;
518    let tlv = token_account_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
519    require_extension(tlv, EXT_IMMUTABLE_OWNER)
520}
521
522/// Require a token account to carry the `CpiGuard` extension.
523#[inline]
524pub fn require_cpi_guard(token_account: &AccountView<'_>) -> ProgramResult {
525    let data = token_account
526        .try_borrow()
527        .map_err(|_| ProgramError::AccountBorrowFailed)?;
528    let tlv = token_account_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
529    require_extension(tlv, EXT_CPI_GUARD)
530}
531
532/// Require a mint to carry the `ConfidentialTransferMint` extension.
533#[inline]
534pub fn require_confidential_transfer_mint(mint: &AccountView<'_>) -> ProgramResult {
535    let data = mint
536        .try_borrow()
537        .map_err(|_| ProgramError::AccountBorrowFailed)?;
538    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
539    require_extension(tlv, EXT_CONFIDENTIAL_TRANSFER_MINT)
540}
541
542/// Require a token account to carry the `ConfidentialTransferAccount` extension.
543#[inline]
544pub fn require_confidential_transfer_account(token_account: &AccountView<'_>) -> ProgramResult {
545    let data = token_account
546        .try_borrow()
547        .map_err(|_| ProgramError::AccountBorrowFailed)?;
548    let tlv = token_account_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
549    require_extension(tlv, EXT_CONFIDENTIAL_TRANSFER_ACCOUNT)
550}
551
552/// Require a mint to carry the `ScaledUiAmountConfig` extension.
553#[inline]
554pub fn require_scaled_ui_amount_config(mint: &AccountView<'_>) -> ProgramResult {
555    let data = mint
556        .try_borrow()
557        .map_err(|_| ProgramError::AccountBorrowFailed)?;
558    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
559    require_extension(tlv, EXT_SCALED_UI_AMOUNT_CONFIG)
560}
561
562/// Require a mint's `DefaultAccountState` byte to equal `expected`.
563///
564/// Values: `0` Uninitialized, `1` Initialized, `2` Frozen.
565#[inline]
566pub fn require_default_account_state(mint: &AccountView<'_>, expected: u8) -> ProgramResult {
567    let data = mint
568        .try_borrow()
569        .map_err(|_| ProgramError::AccountBorrowFailed)?;
570    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
571    let ext =
572        find_extension(tlv, EXT_DEFAULT_ACCOUNT_STATE).ok_or(ProgramError::InvalidAccountData)?;
573    if ext.is_empty() {
574        return Err(ProgramError::InvalidAccountData);
575    }
576    if ext[0] == expected {
577        Ok(())
578    } else {
579        Err(ProgramError::InvalidAccountData)
580    }
581}
582
583/// Require a mint's `InterestBearingConfig` rate-authority to equal `expected`.
584///
585/// Layout: `[rate_authority: 32][initialization_timestamp: 8][pre_update_average_rate: 2][last_update_timestamp: 8][current_rate: 2]`.
586#[inline]
587pub fn require_interest_bearing_authority(
588    mint: &AccountView<'_>,
589    expected: &Address,
590) -> ProgramResult {
591    let data = mint
592        .try_borrow()
593        .map_err(|_| ProgramError::AccountBorrowFailed)?;
594    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
595    let ext =
596        find_extension(tlv, EXT_INTEREST_BEARING_CONFIG).ok_or(ProgramError::InvalidAccountData)?;
597    if ext.len() < 32 {
598        return Err(ProgramError::InvalidAccountData);
599    }
600    if crate::address::keys_eq_bytes(&ext[..32], expected.as_array()) {
601        Ok(())
602    } else {
603        Err(ProgramError::IncorrectAuthority)
604    }
605}
606
607/// Require a mint's `TransferFeeConfig` transfer-fee-config authority to equal `expected`.
608///
609/// Layout prefix: `[transfer_fee_config_authority: 32][withdraw_withheld_authority: 32]...`.
610#[inline]
611pub fn require_transfer_fee_config_authority(
612    mint: &AccountView<'_>,
613    expected: &Address,
614) -> ProgramResult {
615    let data = mint
616        .try_borrow()
617        .map_err(|_| ProgramError::AccountBorrowFailed)?;
618    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
619    let ext =
620        find_extension(tlv, EXT_TRANSFER_FEE_CONFIG).ok_or(ProgramError::InvalidAccountData)?;
621    if ext.len() < 32 {
622        return Err(ProgramError::InvalidAccountData);
623    }
624    if crate::address::keys_eq_bytes(&ext[..32], expected.as_array()) {
625        Ok(())
626    } else {
627        Err(ProgramError::IncorrectAuthority)
628    }
629}
630
631/// Require a mint's `TransferFeeConfig` withdraw-withheld-authority to equal `expected`.
632#[inline]
633pub fn require_transfer_fee_withdraw_authority(
634    mint: &AccountView<'_>,
635    expected: &Address,
636) -> ProgramResult {
637    let data = mint
638        .try_borrow()
639        .map_err(|_| ProgramError::AccountBorrowFailed)?;
640    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
641    let ext =
642        find_extension(tlv, EXT_TRANSFER_FEE_CONFIG).ok_or(ProgramError::InvalidAccountData)?;
643    if ext.len() < 64 {
644        return Err(ProgramError::InvalidAccountData);
645    }
646    if crate::address::keys_eq_bytes(&ext[32..64], expected.as_array()) {
647        Ok(())
648    } else {
649        Err(ProgramError::IncorrectAuthority)
650    }
651}
652
653#[cfg(test)]
654mod tests {
655    extern crate alloc;
656    use super::*;
657
658    /// Build a mint buffer in the **real** Token-2022 on-chain layout:
659    /// 82 bytes of Mint base, 83 bytes of zero padding, one AccountType
660    /// byte, then a single TLV entry.
661    ///
662    /// A previous iteration of this helper elided the padding region
663    /// and pushed the AccountType byte directly after the 82-byte
664    /// base. The parser was wrong in exactly the complementary way, so
665    /// the two wrongnesses aligned and the tests passed while the
666    /// production code silently mis-read every real mainnet mint. This
667    /// helper now matches `spl-token-2022`'s `validate_account_type`
668    /// (which keys on `bytes[BASE_ACCOUNT_LENGTH]` where
669    /// `BASE_ACCOUNT_LENGTH = 165`).
670    fn mint_with_exts(exts: &[(u16, &[u8])]) -> alloc::vec::Vec<u8> {
671        // 82 base + 83 padding = 165 bytes, then AccountType, then TLV.
672        let mut v = alloc::vec![0u8; ACCOUNT_TYPE_OFFSET];
673        v.push(ACCOUNT_TYPE_MINT);
674        for (ty, payload) in exts {
675            v.extend_from_slice(&ty.to_le_bytes());
676            v.extend_from_slice(&(payload.len() as u16).to_le_bytes());
677            v.extend_from_slice(payload);
678        }
679        debug_assert!(v.len() > TLV_OFFSET);
680        v
681    }
682
683    /// Single-extension convenience wrapper. Delegates to [`mint_with_exts`].
684    fn one_ext_mint(ext_type: u16, payload: &[u8]) -> alloc::vec::Vec<u8> {
685        mint_with_exts(&[(ext_type, payload)])
686    }
687
688    /// Build a token-account buffer in the real layout: 165 base bytes
689    /// then AccountType then TLV.
690    fn token_account_with_exts(exts: &[(u16, &[u8])]) -> alloc::vec::Vec<u8> {
691        let mut v = alloc::vec![0u8; BASE_TOKEN_LEN];
692        v.push(ACCOUNT_TYPE_TOKEN);
693        for (ty, payload) in exts {
694            v.extend_from_slice(&ty.to_le_bytes());
695            v.extend_from_slice(&(payload.len() as u16).to_le_bytes());
696            v.extend_from_slice(payload);
697        }
698        v
699    }
700
701    // ---------------------------------------------------------------------
702
703    #[test]
704    fn offset_constants_match_authoritative_spec() {
705        // Values anchored to the spl-token-2022 wire layout.
706        assert_eq!(BASE_MINT_LEN, 82);
707        assert_eq!(BASE_TOKEN_LEN, 165);
708        assert_eq!(ACCOUNT_TYPE_OFFSET, 165);
709        assert_eq!(TLV_OFFSET, 166);
710        assert_eq!(MINT_EXTENSION_PADDING_START, 82);
711        assert_eq!(MINT_EXTENSION_PADDING_END, 165);
712        assert_eq!(TOKEN_MULTISIG_LEN, 355);
713        assert_eq!(ACCOUNT_TYPE_MINT, 0x01);
714        assert_eq!(ACCOUNT_TYPE_TOKEN, 0x02);
715        assert_eq!(EXT_CONFIDENTIAL_MINT_BURN, 24);
716        assert_eq!(EXT_SCALED_UI_AMOUNT_CONFIG, 25);
717        assert_eq!(EXT_PAUSABLE_CONFIG, 26);
718        assert_eq!(EXT_PAUSABLE_ACCOUNT, 27);
719        assert_eq!(EXT_PERMISSIONED_BURN, 28);
720    }
721
722    #[test]
723    fn real_layout_mint_tlv_region_starts_at_166() {
724        // Build a real-layout mint whose only extension is NonTransferable,
725        // placed at offset 166.
726        let data = one_ext_mint(EXT_NON_TRANSFERABLE, &[]);
727        let tlv = mint_tlv_region(&data).expect("extended mint must yield TLV region");
728        // TLV must begin at offset 166, not at offset 83.
729        assert_eq!(tlv.as_ptr() as usize - data.as_ptr() as usize, TLV_OFFSET);
730        // First four bytes are type=9, length=0.
731        assert_eq!(u16::from_le_bytes([tlv[0], tlv[1]]), EXT_NON_TRANSFERABLE);
732        assert_eq!(u16::from_le_bytes([tlv[2], tlv[3]]), 0);
733    }
734
735    #[test]
736    fn real_layout_mint_padding_is_not_treated_as_tlv() {
737        // This is the exact shape that the previous implementation
738        // mis-parsed: 82 base + 83 zero padding + AccountType=1 +
739        // genuine TLV entry for TransferHook at offset 166. The old
740        // parser read zero padding at offset 83 as type=0/length=0 and
741        // short-circuited to None. The corrected parser must find the
742        // real entry.
743        let data = one_ext_mint(EXT_TRANSFER_HOOK, &[0u8; 64]);
744        let tlv = mint_tlv_region(&data).expect("tlv region");
745        assert!(find_extension(tlv, EXT_TRANSFER_HOOK).is_some());
746    }
747
748    // ---------------------------------------------------------------------
749
750    #[test]
751    fn find_extension_returns_payload_slice() {
752        let data = one_ext_mint(EXT_NON_TRANSFERABLE, &[]);
753        let tlv = mint_tlv_region(&data).unwrap();
754        assert!(find_extension(tlv, EXT_NON_TRANSFERABLE).is_some());
755    }
756
757    #[test]
758    fn find_extension_returns_none_when_absent() {
759        let data = one_ext_mint(EXT_NON_TRANSFERABLE, &[]);
760        let tlv = mint_tlv_region(&data).unwrap();
761        assert!(find_extension(tlv, EXT_TRANSFER_HOOK).is_none());
762    }
763
764    #[test]
765    fn find_extension_bails_on_malformed_length() {
766        // Real-layout mint: 82 + 83 padding + type byte, then a
767        // corrupt TLV header claiming 999 bytes of data that are not
768        // present. Scanner must return None, not panic.
769        let mut data = alloc::vec![0u8; ACCOUNT_TYPE_OFFSET];
770        data.push(ACCOUNT_TYPE_MINT);
771        data.extend_from_slice(&EXT_TRANSFER_HOOK.to_le_bytes());
772        data.extend_from_slice(&999u16.to_le_bytes());
773        let tlv = mint_tlv_region(&data).unwrap();
774        assert!(find_extension(tlv, EXT_TRANSFER_HOOK).is_none());
775        assert_eq!(
776            forbid_extension(tlv, EXT_TRANSFER_HOOK),
777            Err(ProgramError::InvalidAccountData),
778            "a malformed forbidden extension must not look absent",
779        );
780    }
781
782    #[test]
783    fn allowlist_rejects_unknown_duplicate_and_truncated_entries() {
784        let allowed = [EXT_METADATA_POINTER];
785        let known = mint_with_exts(&[(EXT_METADATA_POINTER, &[0u8; 64])]);
786        validate_extension_allowlist(mint_tlv_region(&known).unwrap(), &allowed).unwrap();
787
788        let unknown = mint_with_exts(&[(MAX_KNOWN_EXTENSION_TYPE + 1, &[])]);
789        assert_eq!(
790            validate_extension_allowlist(mint_tlv_region(&unknown).unwrap(), &allowed),
791            Err(ProgramError::InvalidAccountData),
792        );
793
794        let duplicate = mint_with_exts(&[
795            (EXT_METADATA_POINTER, &[0u8; 64]),
796            (EXT_METADATA_POINTER, &[0u8; 64]),
797        ]);
798        assert_eq!(
799            validate_extension_allowlist(mint_tlv_region(&duplicate).unwrap(), &allowed),
800            Err(ProgramError::InvalidAccountData),
801        );
802
803        let truncated = [EXT_METADATA_POINTER as u8, 0, 4, 0, 1];
804        assert_eq!(
805            validate_extension_allowlist(&truncated, &allowed),
806            Err(ProgramError::InvalidAccountData),
807        );
808    }
809
810    #[test]
811    fn find_extension_finds_second_entry() {
812        let data = mint_with_exts(&[
813            (EXT_METADATA_POINTER, &[1u8; 64]),
814            (EXT_PERMANENT_DELEGATE, &[2u8; 32]),
815        ]);
816        let tlv = mint_tlv_region(&data).unwrap();
817        let perm = find_extension(tlv, EXT_PERMANENT_DELEGATE).unwrap();
818        assert_eq!(perm, &[2u8; 32]);
819    }
820
821    #[test]
822    fn extension_policy_requires_and_forbids_extensions() {
823        let data = mint_with_exts(&[
824            (EXT_CONFIDENTIAL_TRANSFER_MINT, &[0u8; 1]),
825            (EXT_SCALED_UI_AMOUNT_CONFIG, &[0u8; 1]),
826        ]);
827        let tlv = mint_tlv_region(&data).unwrap();
828        let policy = ExtensionPolicy::new(
829            &[EXT_CONFIDENTIAL_TRANSFER_MINT, EXT_SCALED_UI_AMOUNT_CONFIG],
830            &[EXT_TRANSFER_HOOK],
831        );
832
833        validate_extension_policy(tlv, &policy).unwrap();
834
835        let rejected = ExtensionPolicy::new(
836            &[EXT_CONFIDENTIAL_TRANSFER_MINT],
837            &[EXT_SCALED_UI_AMOUNT_CONFIG],
838        );
839        assert_eq!(
840            validate_extension_policy(tlv, &rejected),
841            Err(ProgramError::InvalidAccountData)
842        );
843    }
844
845    #[test]
846    fn token_account_policy_sees_cpi_guard_and_confidential_account() {
847        let data = token_account_with_exts(&[
848            (EXT_CPI_GUARD, &[]),
849            (EXT_CONFIDENTIAL_TRANSFER_ACCOUNT, &[0u8; 1]),
850        ]);
851        let tlv = token_account_tlv_region(&data).unwrap();
852
853        validate_extension_policy(
854            tlv,
855            &ExtensionPolicy::new(&[EXT_CPI_GUARD, EXT_CONFIDENTIAL_TRANSFER_ACCOUNT], &[]),
856        )
857        .unwrap();
858    }
859
860    // ---------------------------------------------------------------------
861
862    #[test]
863    fn mint_tlv_region_rejects_short_account() {
864        // Anything <= TLV_OFFSET (166) has no extension region.
865        let data = alloc::vec![0u8; 40];
866        assert!(mint_tlv_region(&data).is_none());
867        let data = alloc::vec![0u8; TLV_OFFSET];
868        assert!(mint_tlv_region(&data).is_none());
869    }
870
871    #[test]
872    fn mint_tlv_region_rejects_wrong_account_kind() {
873        // A 166-byte buffer whose AccountType byte reads as Token
874        // (0x02) must not decode as a mint.
875        let mut data = alloc::vec![0u8; ACCOUNT_TYPE_OFFSET];
876        data.push(ACCOUNT_TYPE_TOKEN);
877        data.push(0); // make length > TLV_OFFSET
878        assert!(mint_tlv_region(&data).is_none());
879    }
880
881    #[test]
882    fn tlv_regions_reject_multisig_collision_and_dirty_mint_padding() {
883        let mut multisig = alloc::vec![0u8; TOKEN_MULTISIG_LEN];
884        multisig[ACCOUNT_TYPE_OFFSET] = ACCOUNT_TYPE_MINT;
885        assert!(mint_tlv_region(&multisig).is_none());
886        multisig[ACCOUNT_TYPE_OFFSET] = ACCOUNT_TYPE_TOKEN;
887        assert!(token_account_tlv_region(&multisig).is_none());
888
889        let mut dirty_mint = one_ext_mint(EXT_NON_TRANSFERABLE, &[]);
890        dirty_mint[MINT_EXTENSION_PADDING_START] = 1;
891        assert!(mint_tlv_region(&dirty_mint).is_none());
892    }
893
894    #[test]
895    fn mint_tlv_region_accepts_zero_kind_byte() {
896        // Permissive init sequencing: a freshly-reallocated mint may
897        // have AccountType still zero. The scanner tolerates it.
898        let mut data = alloc::vec![0u8; ACCOUNT_TYPE_OFFSET];
899        data.push(0u8);
900        data.push(0); // length > TLV_OFFSET
901        assert!(mint_tlv_region(&data).is_some());
902    }
903
904    #[test]
905    fn token_account_tlv_region_accepts_zero_kind_byte() {
906        let mut data = alloc::vec![0u8; BASE_TOKEN_LEN];
907        data.push(0u8);
908        data.push(0); // length > TLV_OFFSET
909        assert!(token_account_tlv_region(&data).is_some());
910    }
911
912    #[test]
913    fn token_account_tlv_region_rejects_mint_kind() {
914        let mut data = alloc::vec![0u8; BASE_TOKEN_LEN];
915        data.push(ACCOUNT_TYPE_MINT);
916        data.push(0);
917        assert!(token_account_tlv_region(&data).is_none());
918    }
919
920    #[test]
921    fn token_account_tlv_region_returns_real_tlv() {
922        let data = token_account_with_exts(&[(EXT_IMMUTABLE_OWNER, &[])]);
923        let tlv = token_account_tlv_region(&data).unwrap();
924        assert_eq!(tlv.as_ptr() as usize - data.as_ptr() as usize, TLV_OFFSET);
925        assert!(find_extension(tlv, EXT_IMMUTABLE_OWNER).is_some());
926    }
927}