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