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// ── Declarative require_* helpers for the common cases ────────────────
204
205/// Require a mint to carry the `NonTransferable` extension.
206///
207/// Use when a program is designed to only ever mint soulbound tokens.
208#[inline]
209pub fn require_non_transferable(mint: &AccountView) -> ProgramResult {
210    let data = mint
211        .try_borrow()
212        .map_err(|_| ProgramError::AccountBorrowFailed)?;
213    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
214    if find_extension(tlv, EXT_NON_TRANSFERABLE).is_some() {
215        Ok(())
216    } else {
217        Err(ProgramError::InvalidAccountData)
218    }
219}
220
221/// Require a mint's `MintCloseAuthority` extension to equal `expected`.
222#[inline]
223pub fn require_mint_close_authority(mint: &AccountView, expected: &Address) -> ProgramResult {
224    let data = mint
225        .try_borrow()
226        .map_err(|_| ProgramError::AccountBorrowFailed)?;
227    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
228    let ext =
229        find_extension(tlv, EXT_MINT_CLOSE_AUTHORITY).ok_or(ProgramError::InvalidAccountData)?;
230    if ext.len() < 32 {
231        return Err(ProgramError::InvalidAccountData);
232    }
233    if &ext[..32] == expected.as_array() {
234        Ok(())
235    } else {
236        Err(ProgramError::IncorrectAuthority)
237    }
238}
239
240/// Require a mint's `PermanentDelegate` extension to equal `expected`.
241#[inline]
242pub fn require_permanent_delegate(mint: &AccountView, expected: &Address) -> ProgramResult {
243    let data = mint
244        .try_borrow()
245        .map_err(|_| ProgramError::AccountBorrowFailed)?;
246    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
247    let ext =
248        find_extension(tlv, EXT_PERMANENT_DELEGATE).ok_or(ProgramError::InvalidAccountData)?;
249    if ext.len() < 32 {
250        return Err(ProgramError::InvalidAccountData);
251    }
252    if &ext[..32] == expected.as_array() {
253        Ok(())
254    } else {
255        Err(ProgramError::IncorrectAuthority)
256    }
257}
258
259/// Require a mint's `TransferHook` program id to equal `expected`.
260///
261/// `TransferHook` layout: `[authority: 32][program_id: 32]`. This
262/// validates the second field. Use [`require_transfer_hook_authority`]
263/// for the first.
264#[inline]
265pub fn require_transfer_hook_program(mint: &AccountView, expected: &Address) -> ProgramResult {
266    let data = mint
267        .try_borrow()
268        .map_err(|_| ProgramError::AccountBorrowFailed)?;
269    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
270    let ext = find_extension(tlv, EXT_TRANSFER_HOOK).ok_or(ProgramError::InvalidAccountData)?;
271    if ext.len() < 64 {
272        return Err(ProgramError::InvalidAccountData);
273    }
274    if &ext[32..64] == expected.as_array() {
275        Ok(())
276    } else {
277        Err(ProgramError::IncorrectProgramId)
278    }
279}
280
281/// Require a mint's `TransferHook` authority to equal `expected`.
282#[inline]
283pub fn require_transfer_hook_authority(mint: &AccountView, expected: &Address) -> ProgramResult {
284    let data = mint
285        .try_borrow()
286        .map_err(|_| ProgramError::AccountBorrowFailed)?;
287    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
288    let ext = find_extension(tlv, EXT_TRANSFER_HOOK).ok_or(ProgramError::InvalidAccountData)?;
289    if ext.len() < 32 {
290        return Err(ProgramError::InvalidAccountData);
291    }
292    if &ext[..32] == expected.as_array() {
293        Ok(())
294    } else {
295        Err(ProgramError::IncorrectAuthority)
296    }
297}
298
299/// Require a mint's `MetadataPointer` metadata-address to equal `expected`.
300///
301/// `MetadataPointer` layout: `[authority: 32][metadata_address: 32]`.
302#[inline]
303pub fn require_metadata_pointer_address(mint: &AccountView, expected: &Address) -> ProgramResult {
304    let data = mint
305        .try_borrow()
306        .map_err(|_| ProgramError::AccountBorrowFailed)?;
307    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
308    let ext = find_extension(tlv, EXT_METADATA_POINTER).ok_or(ProgramError::InvalidAccountData)?;
309    if ext.len() < 64 {
310        return Err(ProgramError::InvalidAccountData);
311    }
312    if &ext[32..64] == expected.as_array() {
313        Ok(())
314    } else {
315        Err(ProgramError::InvalidAccountData)
316    }
317}
318
319/// Require a mint's `MetadataPointer` authority to equal `expected`.
320#[inline]
321pub fn require_metadata_pointer_authority(mint: &AccountView, expected: &Address) -> ProgramResult {
322    let data = mint
323        .try_borrow()
324        .map_err(|_| ProgramError::AccountBorrowFailed)?;
325    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
326    let ext = find_extension(tlv, EXT_METADATA_POINTER).ok_or(ProgramError::InvalidAccountData)?;
327    if ext.len() < 32 {
328        return Err(ProgramError::InvalidAccountData);
329    }
330    if &ext[..32] == expected.as_array() {
331        Ok(())
332    } else {
333        Err(ProgramError::IncorrectAuthority)
334    }
335}
336
337/// Require a token account to carry the `ImmutableOwner` extension.
338#[inline]
339pub fn require_immutable_owner(token_account: &AccountView) -> ProgramResult {
340    let data = token_account
341        .try_borrow()
342        .map_err(|_| ProgramError::AccountBorrowFailed)?;
343    let tlv = token_account_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
344    if find_extension(tlv, EXT_IMMUTABLE_OWNER).is_some() {
345        Ok(())
346    } else {
347        Err(ProgramError::InvalidAccountData)
348    }
349}
350
351/// Require a mint's `DefaultAccountState` byte to equal `expected`.
352///
353/// Values: `0` Uninitialized, `1` Initialized, `2` Frozen.
354#[inline]
355pub fn require_default_account_state(mint: &AccountView, expected: u8) -> ProgramResult {
356    let data = mint
357        .try_borrow()
358        .map_err(|_| ProgramError::AccountBorrowFailed)?;
359    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
360    let ext =
361        find_extension(tlv, EXT_DEFAULT_ACCOUNT_STATE).ok_or(ProgramError::InvalidAccountData)?;
362    if ext.is_empty() {
363        return Err(ProgramError::InvalidAccountData);
364    }
365    if ext[0] == expected {
366        Ok(())
367    } else {
368        Err(ProgramError::InvalidAccountData)
369    }
370}
371
372/// Require a mint's `InterestBearingConfig` rate-authority to equal `expected`.
373///
374/// Layout: `[rate_authority: 32][initialization_timestamp: 8][pre_update_average_rate: 2][last_update_timestamp: 8][current_rate: 2]`.
375#[inline]
376pub fn require_interest_bearing_authority(mint: &AccountView, expected: &Address) -> ProgramResult {
377    let data = mint
378        .try_borrow()
379        .map_err(|_| ProgramError::AccountBorrowFailed)?;
380    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
381    let ext =
382        find_extension(tlv, EXT_INTEREST_BEARING_CONFIG).ok_or(ProgramError::InvalidAccountData)?;
383    if ext.len() < 32 {
384        return Err(ProgramError::InvalidAccountData);
385    }
386    if &ext[..32] == expected.as_array() {
387        Ok(())
388    } else {
389        Err(ProgramError::IncorrectAuthority)
390    }
391}
392
393/// Require a mint's `TransferFeeConfig` transfer-fee-config authority to equal `expected`.
394///
395/// Layout prefix: `[transfer_fee_config_authority: 32][withdraw_withheld_authority: 32]...`.
396#[inline]
397pub fn require_transfer_fee_config_authority(
398    mint: &AccountView,
399    expected: &Address,
400) -> ProgramResult {
401    let data = mint
402        .try_borrow()
403        .map_err(|_| ProgramError::AccountBorrowFailed)?;
404    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
405    let ext =
406        find_extension(tlv, EXT_TRANSFER_FEE_CONFIG).ok_or(ProgramError::InvalidAccountData)?;
407    if ext.len() < 32 {
408        return Err(ProgramError::InvalidAccountData);
409    }
410    if &ext[..32] == expected.as_array() {
411        Ok(())
412    } else {
413        Err(ProgramError::IncorrectAuthority)
414    }
415}
416
417/// Require a mint's `TransferFeeConfig` withdraw-withheld-authority to equal `expected`.
418#[inline]
419pub fn require_transfer_fee_withdraw_authority(
420    mint: &AccountView,
421    expected: &Address,
422) -> ProgramResult {
423    let data = mint
424        .try_borrow()
425        .map_err(|_| ProgramError::AccountBorrowFailed)?;
426    let tlv = mint_tlv_region(&data).ok_or(ProgramError::InvalidAccountData)?;
427    let ext =
428        find_extension(tlv, EXT_TRANSFER_FEE_CONFIG).ok_or(ProgramError::InvalidAccountData)?;
429    if ext.len() < 64 {
430        return Err(ProgramError::InvalidAccountData);
431    }
432    if &ext[32..64] == expected.as_array() {
433        Ok(())
434    } else {
435        Err(ProgramError::IncorrectAuthority)
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    extern crate alloc;
442    use super::*;
443
444    /// Build a mint buffer in the **real** Token-2022 on-chain layout:
445    /// 82 bytes of Mint base, 83 bytes of zero padding, one AccountType
446    /// byte, then a single TLV entry.
447    ///
448    /// A previous iteration of this helper elided the padding region
449    /// and pushed the AccountType byte directly after the 82-byte
450    /// base. The parser was wrong in exactly the complementary way, so
451    /// the two wrongnesses aligned and the tests passed while the
452    /// production code silently mis-read every real mainnet mint. This
453    /// helper now matches `spl-token-2022` and pinocchio's
454    /// `validate_account_type` (which keys on
455    /// `bytes[BASE_ACCOUNT_LENGTH]` where `BASE_ACCOUNT_LENGTH = 165`).
456    fn mint_with_exts(exts: &[(u16, &[u8])]) -> alloc::vec::Vec<u8> {
457        // 82 base + 83 padding = 165 bytes, then AccountType, then TLV.
458        let mut v = alloc::vec![0u8; ACCOUNT_TYPE_OFFSET];
459        v.push(ACCOUNT_TYPE_MINT);
460        for (ty, payload) in exts {
461            v.extend_from_slice(&ty.to_le_bytes());
462            v.extend_from_slice(&(payload.len() as u16).to_le_bytes());
463            v.extend_from_slice(payload);
464        }
465        debug_assert!(v.len() > TLV_OFFSET);
466        v
467    }
468
469    /// Single-extension convenience wrapper. Delegates to [`mint_with_exts`].
470    fn one_ext_mint(ext_type: u16, payload: &[u8]) -> alloc::vec::Vec<u8> {
471        mint_with_exts(&[(ext_type, payload)])
472    }
473
474    /// Build a token-account buffer in the real layout: 165 base bytes
475    /// then AccountType then TLV.
476    fn token_account_with_exts(exts: &[(u16, &[u8])]) -> alloc::vec::Vec<u8> {
477        let mut v = alloc::vec![0u8; BASE_TOKEN_LEN];
478        v.push(ACCOUNT_TYPE_TOKEN);
479        for (ty, payload) in exts {
480            v.extend_from_slice(&ty.to_le_bytes());
481            v.extend_from_slice(&(payload.len() as u16).to_le_bytes());
482            v.extend_from_slice(payload);
483        }
484        v
485    }
486
487    // ── Layout invariants (the regression suite for the offset bug) ──────
488
489    #[test]
490    fn offset_constants_match_authoritative_spec() {
491        // Values anchored to spl-token-2022 and pinocchio's reference.
492        assert_eq!(BASE_MINT_LEN, 82);
493        assert_eq!(BASE_TOKEN_LEN, 165);
494        assert_eq!(ACCOUNT_TYPE_OFFSET, 165);
495        assert_eq!(TLV_OFFSET, 166);
496        assert_eq!(MINT_EXTENSION_PADDING_START, 82);
497        assert_eq!(MINT_EXTENSION_PADDING_END, 165);
498        assert_eq!(ACCOUNT_TYPE_MINT, 0x01);
499        assert_eq!(ACCOUNT_TYPE_TOKEN, 0x02);
500    }
501
502    #[test]
503    fn real_layout_mint_tlv_region_starts_at_166() {
504        // Build a real-layout mint whose only extension is NonTransferable,
505        // placed at offset 166.
506        let data = one_ext_mint(EXT_NON_TRANSFERABLE, &[]);
507        let tlv = mint_tlv_region(&data).expect("extended mint must yield TLV region");
508        // TLV must begin at offset 166, not at offset 83.
509        assert_eq!(tlv.as_ptr() as usize - data.as_ptr() as usize, TLV_OFFSET);
510        // First four bytes are type=9, length=0.
511        assert_eq!(u16::from_le_bytes([tlv[0], tlv[1]]), EXT_NON_TRANSFERABLE);
512        assert_eq!(u16::from_le_bytes([tlv[2], tlv[3]]), 0);
513    }
514
515    #[test]
516    fn real_layout_mint_padding_is_not_treated_as_tlv() {
517        // This is the exact shape that the previous implementation
518        // mis-parsed: 82 base + 83 zero padding + AccountType=1 +
519        // genuine TLV entry for TransferHook at offset 166. The old
520        // parser read zero padding at offset 83 as type=0/length=0 and
521        // short-circuited to None. The corrected parser must find the
522        // real entry.
523        let data = one_ext_mint(EXT_TRANSFER_HOOK, &[0u8; 64]);
524        let tlv = mint_tlv_region(&data).expect("tlv region");
525        assert!(find_extension(tlv, EXT_TRANSFER_HOOK).is_some());
526    }
527
528    // ── find_extension core ──────────────────────────────────────────────
529
530    #[test]
531    fn find_extension_returns_payload_slice() {
532        let data = one_ext_mint(EXT_NON_TRANSFERABLE, &[]);
533        let tlv = mint_tlv_region(&data).unwrap();
534        assert!(find_extension(tlv, EXT_NON_TRANSFERABLE).is_some());
535    }
536
537    #[test]
538    fn find_extension_returns_none_when_absent() {
539        let data = one_ext_mint(EXT_NON_TRANSFERABLE, &[]);
540        let tlv = mint_tlv_region(&data).unwrap();
541        assert!(find_extension(tlv, EXT_TRANSFER_HOOK).is_none());
542    }
543
544    #[test]
545    fn find_extension_bails_on_malformed_length() {
546        // Real-layout mint: 82 + 83 padding + type byte, then a
547        // corrupt TLV header claiming 999 bytes of data that are not
548        // present. Scanner must return None, not panic.
549        let mut data = alloc::vec![0u8; ACCOUNT_TYPE_OFFSET];
550        data.push(ACCOUNT_TYPE_MINT);
551        data.extend_from_slice(&EXT_TRANSFER_HOOK.to_le_bytes());
552        data.extend_from_slice(&999u16.to_le_bytes());
553        let tlv = mint_tlv_region(&data).unwrap();
554        assert!(find_extension(tlv, EXT_TRANSFER_HOOK).is_none());
555    }
556
557    #[test]
558    fn find_extension_finds_second_entry() {
559        let data = mint_with_exts(&[
560            (EXT_METADATA_POINTER, &[1u8; 64]),
561            (EXT_PERMANENT_DELEGATE, &[2u8; 32]),
562        ]);
563        let tlv = mint_tlv_region(&data).unwrap();
564        let perm = find_extension(tlv, EXT_PERMANENT_DELEGATE).unwrap();
565        assert_eq!(perm, &[2u8; 32]);
566    }
567
568    // ── Region accept / reject edges ─────────────────────────────────────
569
570    #[test]
571    fn mint_tlv_region_rejects_short_account() {
572        // Anything <= TLV_OFFSET (166) has no extension region.
573        let data = alloc::vec![0u8; 40];
574        assert!(mint_tlv_region(&data).is_none());
575        let data = alloc::vec![0u8; TLV_OFFSET];
576        assert!(mint_tlv_region(&data).is_none());
577    }
578
579    #[test]
580    fn mint_tlv_region_rejects_wrong_account_kind() {
581        // A 166-byte buffer whose AccountType byte reads as Token
582        // (0x02) must not decode as a mint.
583        let mut data = alloc::vec![0u8; ACCOUNT_TYPE_OFFSET];
584        data.push(ACCOUNT_TYPE_TOKEN);
585        data.push(0); // make length > TLV_OFFSET
586        assert!(mint_tlv_region(&data).is_none());
587    }
588
589    #[test]
590    fn mint_tlv_region_accepts_zero_kind_byte() {
591        // Permissive init sequencing: a freshly-reallocated mint may
592        // have AccountType still zero. The scanner tolerates it.
593        let mut data = alloc::vec![0u8; ACCOUNT_TYPE_OFFSET];
594        data.push(0u8);
595        data.push(0); // length > TLV_OFFSET
596        assert!(mint_tlv_region(&data).is_some());
597    }
598
599    #[test]
600    fn token_account_tlv_region_accepts_zero_kind_byte() {
601        let mut data = alloc::vec![0u8; BASE_TOKEN_LEN];
602        data.push(0u8);
603        data.push(0); // length > TLV_OFFSET
604        assert!(token_account_tlv_region(&data).is_some());
605    }
606
607    #[test]
608    fn token_account_tlv_region_rejects_mint_kind() {
609        let mut data = alloc::vec![0u8; BASE_TOKEN_LEN];
610        data.push(ACCOUNT_TYPE_MINT);
611        data.push(0);
612        assert!(token_account_tlv_region(&data).is_none());
613    }
614
615    #[test]
616    fn token_account_tlv_region_returns_real_tlv() {
617        let data = token_account_with_exts(&[(EXT_IMMUTABLE_OWNER, &[])]);
618        let tlv = token_account_tlv_region(&data).unwrap();
619        assert_eq!(tlv.as_ptr() as usize - data.as_ptr() as usize, TLV_OFFSET);
620        assert!(find_extension(tlv, EXT_IMMUTABLE_OWNER).is_some());
621    }
622}