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//! Anchor routes every extension constraint through
8//! `InterfaceAccount<Mint>`, which Borsh-deserializes the whole
9//! account. Quasar has a zero-copy base-layout reader but no TLV
10//! helpers. Pinocchio has nothing. This module fills the gap.
11//!
12//! Every reader here validates only the bytes it reads. No heap
13//! allocation, no full-account decode, no version coupling to
14//! `spl-token-2022`. A program that needs to enforce
15//! `transfer_hook::authority = X` on a mint calls [`require_transfer_hook_authority`]
16//! and pays the cost of a TLV scan plus a 32-byte compare. That is the
17//! cost floor for a correct check.
18//!
19//! ## On-chain layout (authoritative)
20//!
21//! An extended mint is padded to the same length as an extended token
22//! account so that the Token-2022 program cannot confuse the two by
23//! data length alone; the one-byte `AccountType` discriminator at
24//! offset `ACCOUNT_TYPE_OFFSET` (= 165) disambiguates them.
25//!
26//! ```text
27//! Extended mint         : [0..82] Mint base | [82..165] padding | [165] AccountType=1 | [166..] TLV
28//! Extended token account: [0..165] Account base                 | [165] AccountType=2 | [166..] TLV
29//! ```
30//!
31//! Both shapes place the AccountType byte at offset 165 and begin TLV
32//! data at offset 166. `BASE_MINT_LEN` (82) is the length of a *plain*,
33//! non-extended mint and is used by length checks; it is **not** the
34//! offset at which mint extensions live. This layout matches
35//! `spl-token-2022` and the pinocchio reference implementation
36//! (`validate_account_type` keys on `bytes[BASE_ACCOUNT_LENGTH]`
37//! where `BASE_ACCOUNT_LENGTH = 165`).
38//!
39//! ## Extension type constants
40//!
41//! Values are the on-chain `u16` encoding from
42//! `spl-token-2022::extension::ExtensionType`. They are stable wire
43//! values and safe to hard-code. The full set is listed below so
44//! tooling can surface the name for any TLV it encounters.
45
46use crate::{account::AccountView, address::Address, error::ProgramError, result::ProgramResult};
47
48// ── Extension type codes (stable wire values) ────────────────────────
49
50pub const EXT_UNINITIALIZED: u16 = 0;
51pub const EXT_TRANSFER_FEE_CONFIG: u16 = 1;
52pub const EXT_TRANSFER_FEE_AMOUNT: u16 = 2;
53pub const EXT_MINT_CLOSE_AUTHORITY: u16 = 3;
54pub const EXT_CONFIDENTIAL_TRANSFER_MINT: u16 = 4;
55pub const EXT_CONFIDENTIAL_TRANSFER_ACCOUNT: u16 = 5;
56pub const EXT_DEFAULT_ACCOUNT_STATE: u16 = 6;
57pub const EXT_IMMUTABLE_OWNER: u16 = 7;
58pub const EXT_MEMO_TRANSFER: u16 = 8;
59pub const EXT_NON_TRANSFERABLE: u16 = 9;
60pub const EXT_INTEREST_BEARING_CONFIG: u16 = 10;
61pub const EXT_CPI_GUARD: u16 = 11;
62pub const EXT_PERMANENT_DELEGATE: u16 = 12;
63pub const EXT_NON_TRANSFERABLE_ACCOUNT: u16 = 13;
64pub const EXT_TRANSFER_HOOK: u16 = 14;
65pub const EXT_TRANSFER_HOOK_ACCOUNT: u16 = 15;
66pub const EXT_CONFIDENTIAL_TRANSFER_FEE_CONFIG: u16 = 16;
67pub const EXT_CONFIDENTIAL_TRANSFER_FEE_AMOUNT: u16 = 17;
68pub const EXT_METADATA_POINTER: u16 = 18;
69pub const EXT_TOKEN_METADATA: u16 = 19;
70pub const EXT_GROUP_POINTER: u16 = 20;
71pub const EXT_TOKEN_GROUP: u16 = 21;
72pub const EXT_GROUP_MEMBER_POINTER: u16 = 22;
73pub const EXT_TOKEN_GROUP_MEMBER: u16 = 23;
74pub const EXT_SCALED_UI_AMOUNT_CONFIG: u16 = 24;
75pub const EXT_PAUSABLE_CONFIG: u16 = 25;
76pub const EXT_PAUSABLE_ACCOUNT: u16 = 26;
77
78/// Account-type byte: Mint.
79pub const ACCOUNT_TYPE_MINT: u8 = 0x01;
80/// Account-type byte: Token Account.
81pub const ACCOUNT_TYPE_TOKEN: u8 = 0x02;
82
83/// Length of a plain (non-extended) mint's data region.
84///
85/// This is the stride of the `Mint` base struct; it is **not** the
86/// offset at which mint extensions begin (see [`TLV_OFFSET`]). Used
87/// for the length-only check that distinguishes a legacy SPL mint
88/// (exactly 82 bytes) from an extended Token-2022 mint.
89pub const BASE_MINT_LEN: usize = 82;
90/// Length of a plain (non-extended) token-account's data region.
91///
92/// Also equal to [`ACCOUNT_TYPE_OFFSET`]: an extended mint is padded
93/// up to this length so that the AccountType discriminator sits at
94/// the same offset as on an extended token account.
95pub const BASE_TOKEN_LEN: usize = 165;
96/// Offset of the `AccountType` discriminator on any extended
97/// Token-2022 account (mint or token account).
98pub const ACCOUNT_TYPE_OFFSET: usize = BASE_TOKEN_LEN;
99/// Offset at which the TLV extension region begins on any extended
100/// Token-2022 account (mint or token account).
101pub const TLV_OFFSET: usize = ACCOUNT_TYPE_OFFSET + 1;
102/// Start of the mint's extension padding region (82..165). Bytes in
103/// this range are zero-filled and exist purely to equalize the length
104/// of extended mints and extended token accounts.
105pub const MINT_EXTENSION_PADDING_START: usize = BASE_MINT_LEN;
106/// End of the mint extension padding region (exclusive).
107pub const MINT_EXTENSION_PADDING_END: usize = ACCOUNT_TYPE_OFFSET;
108
109// ── TLV scanner ──────────────────────────────────────────────────────
110
111/// Locate an extension in a Token-2022 account's TLV region.
112///
113/// Returns the slice of the extension's data bytes (not including the
114/// 4-byte TLV header) or `None` if the extension is not present.
115///
116/// `tlv_bytes` must be the account data starting at the TLV region
117/// (i.e. `&data[TLV_OFFSET..]` for both mints and token accounts).
118/// Use [`mint_tlv_region`] or [`token_account_tlv_region`] to obtain
119/// this slice safely. Malformed TLVs (length runs past the buffer)
120/// return `None` rather than panic.
121///
122/// One pass, O(n) in the TLV count. No allocation. The caller is
123/// expected to amortize calls by grouping checks.
124#[inline]
125pub fn find_extension<'a>(tlv_bytes: &'a [u8], ext_type: u16) -> Option<&'a [u8]> {
126    let mut cursor = 0usize;
127    while cursor + 4 <= tlv_bytes.len() {
128        let t = u16::from_le_bytes([tlv_bytes[cursor], tlv_bytes[cursor + 1]]);
129        let len = u16::from_le_bytes([tlv_bytes[cursor + 2], tlv_bytes[cursor + 3]]) as usize;
130        let data_start = cursor + 4;
131        let data_end = data_start + len;
132        if data_end > tlv_bytes.len() {
133            return None;
134        }
135        if t == ext_type {
136            return Some(&tlv_bytes[data_start..data_end]);
137        }
138        if t == EXT_UNINITIALIZED {
139            // Uninitialized marker with zero length is a valid
140            // stopping condition. Anything else with type 0 is
141            // malformed padding; we stop scanning to avoid running
142            // off the end through stray bytes.
143            return None;
144        }
145        cursor = data_end;
146    }
147    None
148}
149
150/// Slice the TLV region out of a mint account's data.
151///
152/// Returns `None` if the account is too short to be an *extended*
153/// Token-2022 mint (length must be strictly greater than
154/// [`TLV_OFFSET`]; a plain 82-byte legacy mint has no TLV region).
155///
156/// Performs three validations:
157/// 1. Account length is large enough to contain at least the TLV
158///    header offset (`> TLV_OFFSET`, i.e. >= 166).
159/// 2. The `AccountType` discriminator at [`ACCOUNT_TYPE_OFFSET`]
160///    is either [`ACCOUNT_TYPE_MINT`] (0x01) or `0x00`. We accept
161///    `0x00` for a just-reallocated mint before the Token-2022 program
162///    stamps its account type; every subsequent extension initializer
163///    writes the correct byte, and the TLV scanner tolerates an
164///    all-zero region by hitting `EXT_UNINITIALIZED` on the first
165///    header read. This matches `spl-token-2022`'s permissive init
166///    sequencing.
167/// 3. Returns the tail slice beginning at [`TLV_OFFSET`] (166).
168///
169/// The bytes in `[BASE_MINT_LEN..ACCOUNT_TYPE_OFFSET]` (82..165) are
170/// Token-2022's equalization padding and are intentionally skipped
171/// over; they are not part of the TLV stream.
172#[inline]
173pub fn mint_tlv_region(data: &[u8]) -> Option<&[u8]> {
174    if data.len() <= TLV_OFFSET {
175        return None;
176    }
177    let kind = data[ACCOUNT_TYPE_OFFSET];
178    if kind != ACCOUNT_TYPE_MINT && kind != 0 {
179        return None;
180    }
181    Some(&data[TLV_OFFSET..])
182}
183
184/// Slice the TLV region out of a token-account's data.
185///
186/// Returns `None` if the account is too short to be an *extended*
187/// Token-2022 token account. Same validation shape as
188/// [`mint_tlv_region`] but requires the discriminator at
189/// [`ACCOUNT_TYPE_OFFSET`] be [`ACCOUNT_TYPE_TOKEN`] (0x02) or
190/// `0x00`. TLV data is read from [`TLV_OFFSET`] (166).
191#[inline]
192pub fn token_account_tlv_region(data: &[u8]) -> Option<&[u8]> {
193    if data.len() <= TLV_OFFSET {
194        return None;
195    }
196    let kind = data[ACCOUNT_TYPE_OFFSET];
197    if kind != ACCOUNT_TYPE_TOKEN && kind != 0 {
198        return None;
199    }
200    Some(&data[TLV_OFFSET..])
201}
202
203/// A no-alloc Token-2022 extension policy over a TLV region.
204///
205/// `required` entries must be present; `forbidden` entries must be absent.
206/// This is the common core beneath declarative account constraints and tiny
207/// on-chain probes that want to validate a Token-2022 shape without pulling in
208/// `spl-token-2022` deserialization.
209#[derive(Clone, Copy, Debug, PartialEq, Eq)]
210pub struct ExtensionPolicy<'a> {
211    pub required: &'a [u16],
212    pub forbidden: &'a [u16],
213}
214
215impl<'a> ExtensionPolicy<'a> {
216    #[inline]
217    pub const fn new(required: &'a [u16], forbidden: &'a [u16]) -> Self {
218        Self {
219            required,
220            forbidden,
221        }
222    }
223}
224
225#[inline]
226pub fn require_extension(tlv: &[u8], ext_type: u16) -> ProgramResult {
227    if find_extension(tlv, ext_type).is_some() {
228        Ok(())
229    } else {
230        Err(ProgramError::InvalidAccountData)
231    }
232}
233
234#[inline]
235pub fn forbid_extension(tlv: &[u8], ext_type: u16) -> ProgramResult {
236    if find_extension(tlv, ext_type).is_none() {
237        Ok(())
238    } else {
239        Err(ProgramError::InvalidAccountData)
240    }
241}
242
243#[inline]
244pub fn validate_extension_policy(tlv: &[u8], policy: &ExtensionPolicy<'_>) -> ProgramResult {
245    for ext_type in policy.required {
246        require_extension(tlv, *ext_type)?;
247    }
248    for ext_type in policy.forbidden {
249        forbid_extension(tlv, *ext_type)?;
250    }
251    Ok(())
252}
253
254// ── Declarative require_* helpers for the common cases ────────────────
255
256/// Require a mint to carry the `NonTransferable` extension.
257///
258/// Use when a program is designed to only ever mint soulbound tokens.
259#[inline]
260pub fn require_non_transferable(mint: &AccountView) -> ProgramResult {
261    let data = mint
262        .try_borrow()
263        .map_err(|_| ProgramError::AccountBorrowFailed)?;
264    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
265    if find_extension(tlv, EXT_NON_TRANSFERABLE).is_some() {
266        Ok(())
267    } else {
268        Err(ProgramError::InvalidAccountData)
269    }
270}
271
272/// Require a mint's `MintCloseAuthority` extension to equal `expected`.
273#[inline]
274pub fn require_mint_close_authority(mint: &AccountView, expected: &Address) -> ProgramResult {
275    let data = mint
276        .try_borrow()
277        .map_err(|_| ProgramError::AccountBorrowFailed)?;
278    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
279    let ext =
280        find_extension(tlv, EXT_MINT_CLOSE_AUTHORITY).ok_or(ProgramError::InvalidAccountData)?;
281    if ext.len() < 32 {
282        return Err(ProgramError::InvalidAccountData);
283    }
284    if &ext[..32] == expected.as_array() {
285        Ok(())
286    } else {
287        Err(ProgramError::IncorrectAuthority)
288    }
289}
290
291/// Require a mint's `PermanentDelegate` extension to equal `expected`.
292#[inline]
293pub fn require_permanent_delegate(mint: &AccountView, expected: &Address) -> ProgramResult {
294    let data = mint
295        .try_borrow()
296        .map_err(|_| ProgramError::AccountBorrowFailed)?;
297    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
298    let ext =
299        find_extension(tlv, EXT_PERMANENT_DELEGATE).ok_or(ProgramError::InvalidAccountData)?;
300    if ext.len() < 32 {
301        return Err(ProgramError::InvalidAccountData);
302    }
303    if &ext[..32] == expected.as_array() {
304        Ok(())
305    } else {
306        Err(ProgramError::IncorrectAuthority)
307    }
308}
309
310/// Require a mint's `TransferHook` program id to equal `expected`.
311///
312/// `TransferHook` layout: `[authority: 32][program_id: 32]`. This
313/// validates the second field. Use [`require_transfer_hook_authority`]
314/// for the first.
315#[inline]
316pub fn require_transfer_hook_program(mint: &AccountView, expected: &Address) -> ProgramResult {
317    let data = mint
318        .try_borrow()
319        .map_err(|_| ProgramError::AccountBorrowFailed)?;
320    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
321    let ext = find_extension(tlv, EXT_TRANSFER_HOOK).ok_or(ProgramError::InvalidAccountData)?;
322    if ext.len() < 64 {
323        return Err(ProgramError::InvalidAccountData);
324    }
325    if &ext[32..64] == expected.as_array() {
326        Ok(())
327    } else {
328        Err(ProgramError::IncorrectProgramId)
329    }
330}
331
332/// Require a mint's `TransferHook` authority to equal `expected`.
333#[inline]
334pub fn require_transfer_hook_authority(mint: &AccountView, expected: &Address) -> ProgramResult {
335    let data = mint
336        .try_borrow()
337        .map_err(|_| ProgramError::AccountBorrowFailed)?;
338    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
339    let ext = find_extension(tlv, EXT_TRANSFER_HOOK).ok_or(ProgramError::InvalidAccountData)?;
340    if ext.len() < 32 {
341        return Err(ProgramError::InvalidAccountData);
342    }
343    if &ext[..32] == expected.as_array() {
344        Ok(())
345    } else {
346        Err(ProgramError::IncorrectAuthority)
347    }
348}
349
350/// Require a mint's `MetadataPointer` metadata-address to equal `expected`.
351///
352/// `MetadataPointer` layout: `[authority: 32][metadata_address: 32]`.
353#[inline]
354pub fn require_metadata_pointer_address(mint: &AccountView, expected: &Address) -> ProgramResult {
355    let data = mint
356        .try_borrow()
357        .map_err(|_| ProgramError::AccountBorrowFailed)?;
358    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
359    let ext = find_extension(tlv, EXT_METADATA_POINTER).ok_or(ProgramError::InvalidAccountData)?;
360    if ext.len() < 64 {
361        return Err(ProgramError::InvalidAccountData);
362    }
363    if &ext[32..64] == expected.as_array() {
364        Ok(())
365    } else {
366        Err(ProgramError::InvalidAccountData)
367    }
368}
369
370/// Require a mint's `MetadataPointer` authority to equal `expected`.
371#[inline]
372pub fn require_metadata_pointer_authority(mint: &AccountView, expected: &Address) -> ProgramResult {
373    let data = mint
374        .try_borrow()
375        .map_err(|_| ProgramError::AccountBorrowFailed)?;
376    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
377    let ext = find_extension(tlv, EXT_METADATA_POINTER).ok_or(ProgramError::InvalidAccountData)?;
378    if ext.len() < 32 {
379        return Err(ProgramError::InvalidAccountData);
380    }
381    if &ext[..32] == expected.as_array() {
382        Ok(())
383    } else {
384        Err(ProgramError::IncorrectAuthority)
385    }
386}
387
388/// Require a token account to carry the `ImmutableOwner` extension.
389#[inline]
390pub fn require_immutable_owner(token_account: &AccountView) -> ProgramResult {
391    let data = token_account
392        .try_borrow()
393        .map_err(|_| ProgramError::AccountBorrowFailed)?;
394    let tlv = token_account_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
395    require_extension(tlv, EXT_IMMUTABLE_OWNER)
396}
397
398/// Require a token account to carry the `CpiGuard` extension.
399#[inline]
400pub fn require_cpi_guard(token_account: &AccountView) -> ProgramResult {
401    let data = token_account
402        .try_borrow()
403        .map_err(|_| ProgramError::AccountBorrowFailed)?;
404    let tlv = token_account_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
405    require_extension(tlv, EXT_CPI_GUARD)
406}
407
408/// Require a mint to carry the `ConfidentialTransferMint` extension.
409#[inline]
410pub fn require_confidential_transfer_mint(mint: &AccountView) -> ProgramResult {
411    let data = mint
412        .try_borrow()
413        .map_err(|_| ProgramError::AccountBorrowFailed)?;
414    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
415    require_extension(tlv, EXT_CONFIDENTIAL_TRANSFER_MINT)
416}
417
418/// Require a token account to carry the `ConfidentialTransferAccount` extension.
419#[inline]
420pub fn require_confidential_transfer_account(token_account: &AccountView) -> ProgramResult {
421    let data = token_account
422        .try_borrow()
423        .map_err(|_| ProgramError::AccountBorrowFailed)?;
424    let tlv = token_account_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
425    require_extension(tlv, EXT_CONFIDENTIAL_TRANSFER_ACCOUNT)
426}
427
428/// Require a mint to carry the `ScaledUiAmountConfig` extension.
429#[inline]
430pub fn require_scaled_ui_amount_config(mint: &AccountView) -> ProgramResult {
431    let data = mint
432        .try_borrow()
433        .map_err(|_| ProgramError::AccountBorrowFailed)?;
434    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
435    require_extension(tlv, EXT_SCALED_UI_AMOUNT_CONFIG)
436}
437
438/// Require a mint's `DefaultAccountState` byte to equal `expected`.
439///
440/// Values: `0` Uninitialized, `1` Initialized, `2` Frozen.
441#[inline]
442pub fn require_default_account_state(mint: &AccountView, expected: u8) -> ProgramResult {
443    let data = mint
444        .try_borrow()
445        .map_err(|_| ProgramError::AccountBorrowFailed)?;
446    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
447    let ext =
448        find_extension(tlv, EXT_DEFAULT_ACCOUNT_STATE).ok_or(ProgramError::InvalidAccountData)?;
449    if ext.is_empty() {
450        return Err(ProgramError::InvalidAccountData);
451    }
452    if ext[0] == expected {
453        Ok(())
454    } else {
455        Err(ProgramError::InvalidAccountData)
456    }
457}
458
459/// Require a mint's `InterestBearingConfig` rate-authority to equal `expected`.
460///
461/// Layout: `[rate_authority: 32][initialization_timestamp: 8][pre_update_average_rate: 2][last_update_timestamp: 8][current_rate: 2]`.
462#[inline]
463pub fn require_interest_bearing_authority(mint: &AccountView, expected: &Address) -> ProgramResult {
464    let data = mint
465        .try_borrow()
466        .map_err(|_| ProgramError::AccountBorrowFailed)?;
467    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
468    let ext =
469        find_extension(tlv, EXT_INTEREST_BEARING_CONFIG).ok_or(ProgramError::InvalidAccountData)?;
470    if ext.len() < 32 {
471        return Err(ProgramError::InvalidAccountData);
472    }
473    if &ext[..32] == expected.as_array() {
474        Ok(())
475    } else {
476        Err(ProgramError::IncorrectAuthority)
477    }
478}
479
480/// Require a mint's `TransferFeeConfig` transfer-fee-config authority to equal `expected`.
481///
482/// Layout prefix: `[transfer_fee_config_authority: 32][withdraw_withheld_authority: 32]...`.
483#[inline]
484pub fn require_transfer_fee_config_authority(
485    mint: &AccountView,
486    expected: &Address,
487) -> ProgramResult {
488    let data = mint
489        .try_borrow()
490        .map_err(|_| ProgramError::AccountBorrowFailed)?;
491    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
492    let ext =
493        find_extension(tlv, EXT_TRANSFER_FEE_CONFIG).ok_or(ProgramError::InvalidAccountData)?;
494    if ext.len() < 32 {
495        return Err(ProgramError::InvalidAccountData);
496    }
497    if &ext[..32] == expected.as_array() {
498        Ok(())
499    } else {
500        Err(ProgramError::IncorrectAuthority)
501    }
502}
503
504/// Require a mint's `TransferFeeConfig` withdraw-withheld-authority to equal `expected`.
505#[inline]
506pub fn require_transfer_fee_withdraw_authority(
507    mint: &AccountView,
508    expected: &Address,
509) -> ProgramResult {
510    let data = mint
511        .try_borrow()
512        .map_err(|_| ProgramError::AccountBorrowFailed)?;
513    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
514    let ext =
515        find_extension(tlv, EXT_TRANSFER_FEE_CONFIG).ok_or(ProgramError::InvalidAccountData)?;
516    if ext.len() < 64 {
517        return Err(ProgramError::InvalidAccountData);
518    }
519    if &ext[32..64] == expected.as_array() {
520        Ok(())
521    } else {
522        Err(ProgramError::IncorrectAuthority)
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    extern crate alloc;
529    use super::*;
530
531    /// Build a mint buffer in the **real** Token-2022 on-chain layout:
532    /// 82 bytes of Mint base, 83 bytes of zero padding, one AccountType
533    /// byte, then a single TLV entry.
534    ///
535    /// A previous iteration of this helper elided the padding region
536    /// and pushed the AccountType byte directly after the 82-byte
537    /// base. The parser was wrong in exactly the complementary way, so
538    /// the two wrongnesses aligned and the tests passed while the
539    /// production code silently mis-read every real mainnet mint. This
540    /// helper now matches `spl-token-2022` and pinocchio's
541    /// `validate_account_type` (which keys on
542    /// `bytes[BASE_ACCOUNT_LENGTH]` where `BASE_ACCOUNT_LENGTH = 165`).
543    fn mint_with_exts(exts: &[(u16, &[u8])]) -> alloc::vec::Vec<u8> {
544        // 82 base + 83 padding = 165 bytes, then AccountType, then TLV.
545        let mut v = alloc::vec![0u8; ACCOUNT_TYPE_OFFSET];
546        v.push(ACCOUNT_TYPE_MINT);
547        for (ty, payload) in exts {
548            v.extend_from_slice(&ty.to_le_bytes());
549            v.extend_from_slice(&(payload.len() as u16).to_le_bytes());
550            v.extend_from_slice(payload);
551        }
552        debug_assert!(v.len() > TLV_OFFSET);
553        v
554    }
555
556    /// Single-extension convenience wrapper. Delegates to [`mint_with_exts`].
557    fn one_ext_mint(ext_type: u16, payload: &[u8]) -> alloc::vec::Vec<u8> {
558        mint_with_exts(&[(ext_type, payload)])
559    }
560
561    /// Build a token-account buffer in the real layout: 165 base bytes
562    /// then AccountType then TLV.
563    fn token_account_with_exts(exts: &[(u16, &[u8])]) -> alloc::vec::Vec<u8> {
564        let mut v = alloc::vec![0u8; BASE_TOKEN_LEN];
565        v.push(ACCOUNT_TYPE_TOKEN);
566        for (ty, payload) in exts {
567            v.extend_from_slice(&ty.to_le_bytes());
568            v.extend_from_slice(&(payload.len() as u16).to_le_bytes());
569            v.extend_from_slice(payload);
570        }
571        v
572    }
573
574    // ── Layout invariants (the regression suite for the offset bug) ──────
575
576    #[test]
577    fn offset_constants_match_authoritative_spec() {
578        // Values anchored to spl-token-2022 and pinocchio's reference.
579        assert_eq!(BASE_MINT_LEN, 82);
580        assert_eq!(BASE_TOKEN_LEN, 165);
581        assert_eq!(ACCOUNT_TYPE_OFFSET, 165);
582        assert_eq!(TLV_OFFSET, 166);
583        assert_eq!(MINT_EXTENSION_PADDING_START, 82);
584        assert_eq!(MINT_EXTENSION_PADDING_END, 165);
585        assert_eq!(ACCOUNT_TYPE_MINT, 0x01);
586        assert_eq!(ACCOUNT_TYPE_TOKEN, 0x02);
587    }
588
589    #[test]
590    fn real_layout_mint_tlv_region_starts_at_166() {
591        // Build a real-layout mint whose only extension is NonTransferable,
592        // placed at offset 166.
593        let data = one_ext_mint(EXT_NON_TRANSFERABLE, &[]);
594        let tlv = mint_tlv_region(&data).expect("extended mint must yield TLV region");
595        // TLV must begin at offset 166, not at offset 83.
596        assert_eq!(tlv.as_ptr() as usize - data.as_ptr() as usize, TLV_OFFSET);
597        // First four bytes are type=9, length=0.
598        assert_eq!(u16::from_le_bytes([tlv[0], tlv[1]]), EXT_NON_TRANSFERABLE);
599        assert_eq!(u16::from_le_bytes([tlv[2], tlv[3]]), 0);
600    }
601
602    #[test]
603    fn real_layout_mint_padding_is_not_treated_as_tlv() {
604        // This is the exact shape that the previous implementation
605        // mis-parsed: 82 base + 83 zero padding + AccountType=1 +
606        // genuine TLV entry for TransferHook at offset 166. The old
607        // parser read zero padding at offset 83 as type=0/length=0 and
608        // short-circuited to None. The corrected parser must find the
609        // real entry.
610        let data = one_ext_mint(EXT_TRANSFER_HOOK, &[0u8; 64]);
611        let tlv = mint_tlv_region(&data).expect("tlv region");
612        assert!(find_extension(tlv, EXT_TRANSFER_HOOK).is_some());
613    }
614
615    // ── find_extension core ──────────────────────────────────────────────
616
617    #[test]
618    fn find_extension_returns_payload_slice() {
619        let data = one_ext_mint(EXT_NON_TRANSFERABLE, &[]);
620        let tlv = mint_tlv_region(&data).unwrap();
621        assert!(find_extension(tlv, EXT_NON_TRANSFERABLE).is_some());
622    }
623
624    #[test]
625    fn find_extension_returns_none_when_absent() {
626        let data = one_ext_mint(EXT_NON_TRANSFERABLE, &[]);
627        let tlv = mint_tlv_region(&data).unwrap();
628        assert!(find_extension(tlv, EXT_TRANSFER_HOOK).is_none());
629    }
630
631    #[test]
632    fn find_extension_bails_on_malformed_length() {
633        // Real-layout mint: 82 + 83 padding + type byte, then a
634        // corrupt TLV header claiming 999 bytes of data that are not
635        // present. Scanner must return None, not panic.
636        let mut data = alloc::vec![0u8; ACCOUNT_TYPE_OFFSET];
637        data.push(ACCOUNT_TYPE_MINT);
638        data.extend_from_slice(&EXT_TRANSFER_HOOK.to_le_bytes());
639        data.extend_from_slice(&999u16.to_le_bytes());
640        let tlv = mint_tlv_region(&data).unwrap();
641        assert!(find_extension(tlv, EXT_TRANSFER_HOOK).is_none());
642    }
643
644    #[test]
645    fn find_extension_finds_second_entry() {
646        let data = mint_with_exts(&[
647            (EXT_METADATA_POINTER, &[1u8; 64]),
648            (EXT_PERMANENT_DELEGATE, &[2u8; 32]),
649        ]);
650        let tlv = mint_tlv_region(&data).unwrap();
651        let perm = find_extension(tlv, EXT_PERMANENT_DELEGATE).unwrap();
652        assert_eq!(perm, &[2u8; 32]);
653    }
654
655    #[test]
656    fn extension_policy_requires_and_forbids_extensions() {
657        let data = mint_with_exts(&[
658            (EXT_CONFIDENTIAL_TRANSFER_MINT, &[0u8; 1]),
659            (EXT_SCALED_UI_AMOUNT_CONFIG, &[0u8; 1]),
660        ]);
661        let tlv = mint_tlv_region(&data).unwrap();
662        let policy = ExtensionPolicy::new(
663            &[EXT_CONFIDENTIAL_TRANSFER_MINT, EXT_SCALED_UI_AMOUNT_CONFIG],
664            &[EXT_TRANSFER_HOOK],
665        );
666
667        validate_extension_policy(tlv, &policy).unwrap();
668
669        let rejected = ExtensionPolicy::new(
670            &[EXT_CONFIDENTIAL_TRANSFER_MINT],
671            &[EXT_SCALED_UI_AMOUNT_CONFIG],
672        );
673        assert_eq!(
674            validate_extension_policy(tlv, &rejected),
675            Err(ProgramError::InvalidAccountData)
676        );
677    }
678
679    #[test]
680    fn token_account_policy_sees_cpi_guard_and_confidential_account() {
681        let data = token_account_with_exts(&[
682            (EXT_CPI_GUARD, &[]),
683            (EXT_CONFIDENTIAL_TRANSFER_ACCOUNT, &[0u8; 1]),
684        ]);
685        let tlv = token_account_tlv_region(&data).unwrap();
686
687        validate_extension_policy(
688            tlv,
689            &ExtensionPolicy::new(&[EXT_CPI_GUARD, EXT_CONFIDENTIAL_TRANSFER_ACCOUNT], &[]),
690        )
691        .unwrap();
692    }
693
694    // ── Region accept / reject edges ─────────────────────────────────────
695
696    #[test]
697    fn mint_tlv_region_rejects_short_account() {
698        // Anything <= TLV_OFFSET (166) has no extension region.
699        let data = alloc::vec![0u8; 40];
700        assert!(mint_tlv_region(&data).is_none());
701        let data = alloc::vec![0u8; TLV_OFFSET];
702        assert!(mint_tlv_region(&data).is_none());
703    }
704
705    #[test]
706    fn mint_tlv_region_rejects_wrong_account_kind() {
707        // A 166-byte buffer whose AccountType byte reads as Token
708        // (0x02) must not decode as a mint.
709        let mut data = alloc::vec![0u8; ACCOUNT_TYPE_OFFSET];
710        data.push(ACCOUNT_TYPE_TOKEN);
711        data.push(0); // make length > TLV_OFFSET
712        assert!(mint_tlv_region(&data).is_none());
713    }
714
715    #[test]
716    fn mint_tlv_region_accepts_zero_kind_byte() {
717        // Permissive init sequencing: a freshly-reallocated mint may
718        // have AccountType still zero. The scanner tolerates it.
719        let mut data = alloc::vec![0u8; ACCOUNT_TYPE_OFFSET];
720        data.push(0u8);
721        data.push(0); // length > TLV_OFFSET
722        assert!(mint_tlv_region(&data).is_some());
723    }
724
725    #[test]
726    fn token_account_tlv_region_accepts_zero_kind_byte() {
727        let mut data = alloc::vec![0u8; BASE_TOKEN_LEN];
728        data.push(0u8);
729        data.push(0); // length > TLV_OFFSET
730        assert!(token_account_tlv_region(&data).is_some());
731    }
732
733    #[test]
734    fn token_account_tlv_region_rejects_mint_kind() {
735        let mut data = alloc::vec![0u8; BASE_TOKEN_LEN];
736        data.push(ACCOUNT_TYPE_MINT);
737        data.push(0);
738        assert!(token_account_tlv_region(&data).is_none());
739    }
740
741    #[test]
742    fn token_account_tlv_region_returns_real_tlv() {
743        let data = token_account_with_exts(&[(EXT_IMMUTABLE_OWNER, &[])]);
744        let tlv = token_account_tlv_region(&data).unwrap();
745        assert_eq!(tlv.as_ptr() as usize - data.as_ptr() as usize, TLV_OFFSET);
746        assert!(find_extension(tlv, EXT_IMMUTABLE_OWNER).is_some());
747    }
748}