Skip to main content

hopper_solana/
token2022_ext.rs

1//! Token-2022 extension screening.
2//!
3//! Parse the TLV (Type-Length-Value) extension area on Token-2022 mints
4//! and token accounts. Provides both individual extension readers and
5//! blanket safety checks aimed at DeFi programs (AMMs, lending, staking,
6//! escrow) that need to reject exotic extensions that violate their
7//! assumptions.
8//!
9//! ## Token-2022 on-chain layout (authoritative)
10//!
11//! The base `Mint` struct is 82 bytes; the base `Account` struct is 165
12//! bytes. An extended mint is padded up to 165 bytes so that it matches
13//! the length of an extended token account and the `AccountType`
14//! discriminator falls at the same offset on both shapes:
15//!
16//! ```text
17//! Extended mint         : [0..82] Mint base | [82..165] padding | [165] AccountType = 1 | [166..] TLV
18//! Extended token account: [0..165] Account base                 | [165] AccountType = 2 | [166..] TLV
19//! ```
20//!
21//! TLV data always begins at byte 166 for both shapes. Each TLV entry is:
22//!
23//! ```text
24//!   [u16 LE type] [u16 LE length] [length bytes value]
25//! ```
26//!
27//! Extensions are concatenated. The type determines which extension the
28//! TLV entry represents. This matches `spl-token-2022` and the pinocchio
29//! reference implementation (`validate_account_type` keys on
30//! `bytes[BASE_ACCOUNT_LENGTH]` with `BASE_ACCOUNT_LENGTH = 165`).
31
32use hopper_runtime::error::ProgramError;
33
34// ── Extension Type Discriminators (Token-2022 ExtensionType u16 values) ──────
35
36/// Transfer Fee Config extension (mint).
37pub const EXT_TRANSFER_FEE_CONFIG: u16 = 1;
38/// Transfer Fee Amount extension (token account).
39pub const EXT_TRANSFER_FEE_AMOUNT: u16 = 2;
40/// Mint Close Authority extension.
41pub const EXT_MINT_CLOSE_AUTHORITY: u16 = 3;
42/// Confidential Transfer Mint extension.
43pub const EXT_CONFIDENTIAL_TRANSFER_MINT: u16 = 4;
44/// Confidential Transfer Account extension.
45pub const EXT_CONFIDENTIAL_TRANSFER_ACCOUNT: u16 = 5;
46/// Default Account State extension (mint).
47pub const EXT_DEFAULT_ACCOUNT_STATE: u16 = 6;
48/// Immutable Owner extension (token account).
49pub const EXT_IMMUTABLE_OWNER: u16 = 7;
50/// Memo Transfer extension.
51pub const EXT_MEMO_TRANSFER: u16 = 8;
52/// Non-Transferable extension (mint).
53pub const EXT_NON_TRANSFERABLE: u16 = 9;
54/// Interest-Bearing Mint extension.
55pub const EXT_INTEREST_BEARING: u16 = 10;
56/// CPI Guard extension (token account).
57pub const EXT_CPI_GUARD: u16 = 11;
58/// Permanent Delegate extension (mint).
59pub const EXT_PERMANENT_DELEGATE: u16 = 12;
60/// Transfer Hook extension (mint).
61pub const EXT_TRANSFER_HOOK: u16 = 14;
62/// Metadata Pointer extension (mint).
63pub const EXT_METADATA_POINTER: u16 = 18;
64/// Token Metadata extension (mint).
65pub const EXT_TOKEN_METADATA: u16 = 19;
66/// Group Pointer extension (mint).
67pub const EXT_GROUP_POINTER: u16 = 20;
68/// Group Member Pointer extension (mint).
69pub const EXT_GROUP_MEMBER_POINTER: u16 = 22;
70
71/// Base mint account data size (before extensions).
72pub const MINT_BASE_SIZE: usize = 82;
73
74/// Base token account data size (before extensions). Also equal to
75/// [`ACCOUNT_TYPE_OFFSET`]: an extended mint is padded up to this
76/// length so its AccountType discriminator lives at the same offset
77/// as on an extended token account.
78pub const TOKEN_ACCOUNT_BASE_SIZE: usize = 165;
79
80/// Offset of the `AccountType` discriminator on any extended
81/// Token-2022 account (mint or token account).
82pub const ACCOUNT_TYPE_OFFSET: usize = TOKEN_ACCOUNT_BASE_SIZE;
83
84/// Offset at which the TLV extension region begins on any extended
85/// Token-2022 account (mint or token account).
86pub const TLV_OFFSET: usize = ACCOUNT_TYPE_OFFSET + 1;
87
88/// Account-type discriminator byte: Mint.
89pub const ACCOUNT_TYPE_MINT: u8 = 1;
90/// Account-type discriminator byte: Token Account.
91pub const ACCOUNT_TYPE_TOKEN: u8 = 2;
92
93// ── TLV Parsing ──────────────────────────────────────────────────────────────
94
95/// Find the first TLV entry of `ext_type` in a Token-2022 account's data.
96///
97/// Returns the byte slice of the extension value, or `None` if not found.
98/// Works for both mint and token accounts: the TLV region begins at a
99/// fixed offset (166) on both shapes because extended mints carry 83
100/// bytes of padding that equalize them to the token-account length.
101///
102/// The `base_size` parameter is kept for API compatibility; callers
103/// typically pass [`MINT_BASE_SIZE`] or [`TOKEN_ACCOUNT_BASE_SIZE`].
104/// It is used to verify the expected `AccountType` discriminator: a
105/// mint-shaped base is only allowed when the byte at offset 165 is
106/// [`ACCOUNT_TYPE_MINT`] or `0`, and likewise for token accounts.
107/// This rejects mint extensions read out of a token-account buffer
108/// (and vice versa) instead of returning silently wrong answers.
109///
110/// Returns `None` on any of:
111/// - account shorter than [`TLV_OFFSET`] + 1 (plain non-extended account)
112/// - `AccountType` byte does not match `base_size`'s expected shape
113/// - malformed TLV (declared length runs past the end of the buffer)
114#[inline(always)]
115pub fn find_extension_data(data: &[u8], base_size: usize, ext_type: u16) -> Option<&[u8]> {
116    // Must be long enough to hold at least the AccountType byte and
117    // the start of the TLV region.
118    if data.len() <= TLV_OFFSET {
119        return None;
120    }
121
122    // Validate the AccountType discriminator against the caller's
123    // declared shape. `0` is permissive for mid-init accounts.
124    let expected = match base_size {
125        MINT_BASE_SIZE => ACCOUNT_TYPE_MINT,
126        TOKEN_ACCOUNT_BASE_SIZE => ACCOUNT_TYPE_TOKEN,
127        // Unknown base_size: refuse to guess. Historically this
128        // function happily walked any pointer arithmetic the caller
129        // supplied, which is how the offset bug went undetected.
130        _ => return None,
131    };
132    let kind = data[ACCOUNT_TYPE_OFFSET];
133    if kind != expected && kind != 0 {
134        return None;
135    }
136
137    let mut offset = TLV_OFFSET;
138    while offset + 4 <= data.len() {
139        let ty = u16::from_le_bytes([data[offset], data[offset + 1]]);
140        let len = u16::from_le_bytes([data[offset + 2], data[offset + 3]]) as usize;
141        let value_start = offset + 4;
142        let value_end = value_start.checked_add(len)?;
143
144        if value_end > data.len() {
145            return None; // Truncated TLV
146        }
147
148        if ty == ext_type {
149            return Some(&data[value_start..value_end]);
150        }
151
152        // `Uninitialized` (type 0) with zero length is a valid stop
153        // marker on the Token-2022 wire; treating stray zero padding
154        // as an endless sequence of empty TLVs masks real bugs in
155        // producer code.
156        if ty == 0 && len == 0 {
157            return None;
158        }
159
160        offset = value_end;
161    }
162    None
163}
164
165/// Check if a Token-2022 mint account has a specific extension.
166#[inline(always)]
167pub fn mint_has_extension(mint_data: &[u8], ext_type: u16) -> bool {
168    find_extension_data(mint_data, MINT_BASE_SIZE, ext_type).is_some()
169}
170
171/// Check if a Token-2022 token account has a specific extension.
172#[inline(always)]
173pub fn token_has_extension(token_data: &[u8], ext_type: u16) -> bool {
174    find_extension_data(token_data, TOKEN_ACCOUNT_BASE_SIZE, ext_type).is_some()
175}
176
177// ── Safety Checks ────────────────────────────────────────────────────────────
178
179/// Reject mints that have a Transfer Fee Config extension.
180///
181/// Transfer fees silently reduce the amount received, which can break AMM
182/// invariants, lending health checks, and distribution math.
183#[inline(always)]
184pub fn check_no_transfer_fee(mint_data: &[u8]) -> Result<(), ProgramError> {
185    if mint_has_extension(mint_data, EXT_TRANSFER_FEE_CONFIG) {
186        return Err(ProgramError::InvalidAccountData);
187    }
188    Ok(())
189}
190
191/// Reject mints with a Permanent Delegate.
192///
193/// A permanent delegate can burn or transfer tokens from ANY holder at
194/// any time, making escrow and collateral positions unsafe.
195#[inline(always)]
196pub fn check_no_permanent_delegate(mint_data: &[u8]) -> Result<(), ProgramError> {
197    if mint_has_extension(mint_data, EXT_PERMANENT_DELEGATE) {
198        return Err(ProgramError::InvalidAccountData);
199    }
200    Ok(())
201}
202
203/// Reject mints with Confidential Transfer.
204///
205/// Encrypted balances prevent on-chain verification of collateral ratios,
206/// AMM invariants, and distribution correctness.
207#[inline(always)]
208pub fn check_no_confidential_transfer(mint_data: &[u8]) -> Result<(), ProgramError> {
209    if mint_has_extension(mint_data, EXT_CONFIDENTIAL_TRANSFER_MINT) {
210        return Err(ProgramError::InvalidAccountData);
211    }
212    Ok(())
213}
214
215/// Reject non-transferable (soul-bound) mints.
216#[inline(always)]
217pub fn check_transferable(mint_data: &[u8]) -> Result<(), ProgramError> {
218    if mint_has_extension(mint_data, EXT_NON_TRANSFERABLE) {
219        return Err(ProgramError::InvalidAccountData);
220    }
221    Ok(())
222}
223
224/// Reject mints with a Transfer Hook.
225///
226/// Transfer hooks invoke arbitrary programs on every transfer, which
227/// may re-enter or add unbounded CU cost.
228#[inline(always)]
229pub fn check_no_transfer_hook(mint_data: &[u8]) -> Result<(), ProgramError> {
230    if mint_has_extension(mint_data, EXT_TRANSFER_HOOK) {
231        return Err(ProgramError::InvalidAccountData);
232    }
233    Ok(())
234}
235
236/// Blanket safety check: reject mints with any DeFi-unsafe extension.
237///
238/// Rejects: transfer fee, permanent delegate, confidential transfer,
239/// non-transferable, transfer hook.
240///
241/// This is the recommended default for AMM pools, lending markets, and
242/// staking programs.
243#[inline(always)]
244pub fn check_safe_token_2022_mint(mint_data: &[u8]) -> Result<(), ProgramError> {
245    check_no_transfer_fee(mint_data)?;
246    check_no_permanent_delegate(mint_data)?;
247    check_no_confidential_transfer(mint_data)?;
248    check_transferable(mint_data)?;
249    check_no_transfer_hook(mint_data)?;
250    Ok(())
251}
252
253// ── Transfer Fee Reader ──────────────────────────────────────────────────────
254
255/// Transfer fee configuration extracted from a Token-2022 mint.
256///
257/// Layout of the TransferFeeConfig extension value (108 bytes):
258/// ```text
259///   0..32   transfer_fee_config_authority
260///  32..64   withdraw_withheld_authority
261///  64..72   withheld_amount (u64 LE)
262///  72..74   older_epoch (u16 LE)
263///  74..82   older_maximum_fee (u64 LE)
264///  82..84   older_transfer_fee_bps (u16 LE)
265///  84..86   newer_epoch (u16 LE)
266///  86..94   newer_maximum_fee (u64 LE)
267///  94..96   newer_transfer_fee_bps (u16 LE)
268/// ```
269pub struct TransferFeeConfig {
270    /// Current epoch's transfer fee in basis points.
271    pub fee_bps: u16,
272    /// Maximum fee amount for transfers in the current epoch.
273    pub maximum_fee: u64,
274}
275
276/// Read the active transfer fee config from a Token-2022 mint.
277///
278/// Returns the `newer` fee schedule. Protocols should also compare
279/// `current_epoch` against the epoch boundaries if they need the
280/// exact fee for the current slot.
281#[inline(always)]
282pub fn read_transfer_fee_config(mint_data: &[u8]) -> Result<TransferFeeConfig, ProgramError> {
283    let ext = find_extension_data(mint_data, MINT_BASE_SIZE, EXT_TRANSFER_FEE_CONFIG)
284        .ok_or(ProgramError::InvalidAccountData)?;
285
286    // Newer schedule starts at offset 84 within the extension value.
287    if ext.len() < 96 {
288        return Err(ProgramError::InvalidAccountData);
289    }
290
291    let newer_max_fee = u64::from_le_bytes([
292        ext[86], ext[87], ext[88], ext[89], ext[90], ext[91], ext[92], ext[93],
293    ]);
294    let newer_fee_bps = u16::from_le_bytes([ext[94], ext[95]]);
295
296    Ok(TransferFeeConfig {
297        fee_bps: newer_fee_bps,
298        maximum_fee: newer_max_fee,
299    })
300}
301
302// ── Transfer Hook Reader ─────────────────────────────────────────────────────
303
304/// Transfer-hook binding extracted from a Token-2022 mint.
305///
306/// Layout of the TransferHook extension value (64 bytes):
307///
308/// ```text
309///   0..32   authority  (Pubkey - may be rotated via SetTransferHook)
310///  32..64   program_id (Pubkey - the hook program invoked on transfer)
311/// ```
312///
313/// References are borrowed from the mint buffer, so the caller
314/// typically clones `.to_bytes()` into an owned `Address` before
315/// further use. This is R6 audit closure; see
316/// [`examples/hopper-token-2022-transfer-hook`] for the end-to-end
317/// reference program.
318pub struct TransferHook<'a> {
319    /// Authority allowed to update the hook program binding.
320    pub authority: &'a [u8; 32],
321    /// Program ID of the hook that Token-2022 invokes on every transfer.
322    pub program_id: &'a [u8; 32],
323}
324
325/// Read the Transfer Hook binding from a Token-2022 mint, or return
326/// `None` if the mint has no transfer-hook extension.
327///
328/// Returns `Some(TransferHook { authority, program_id })` when the
329/// mint has a `TransferHook` extension with a well-formed 64-byte
330/// value, `None` when the extension is absent, and
331/// `Err(InvalidAccountData)` when the extension is present but
332/// malformed (length < 64, or the underlying TLV is truncated).
333#[inline(always)]
334pub fn read_transfer_hook(mint_data: &[u8]) -> Result<Option<TransferHook<'_>>, ProgramError> {
335    let Some(ext) = find_extension_data(mint_data, MINT_BASE_SIZE, EXT_TRANSFER_HOOK) else {
336        return Ok(None);
337    };
338    if ext.len() < 64 {
339        return Err(ProgramError::InvalidAccountData);
340    }
341
342    // SAFETY: we just bounds-checked ext.len() >= 64; the two 32-byte
343    // subregions are disjoint (0..32, 32..64) and fall inside `ext`.
344    let authority: &[u8; 32] = ext[0..32].try_into().unwrap();
345    let program_id: &[u8; 32] = ext[32..64].try_into().unwrap();
346
347    Ok(Some(TransferHook {
348        authority,
349        program_id,
350    }))
351}
352
353/// Assert that the mint has a transfer-hook extension and that it
354/// invokes the expected program. Pairs with `read_transfer_hook` when
355/// the program's business logic depends on a specific hook binding.
356///
357/// Returns `Err(InvalidAccountData)` if the extension is missing,
358/// malformed, or binds a different program than `expected_program_id`.
359#[inline(always)]
360pub fn check_transfer_hook_program(
361    mint_data: &[u8],
362    expected_program_id: &[u8; 32],
363) -> Result<(), ProgramError> {
364    match read_transfer_hook(mint_data)? {
365        Some(hook) if hook.program_id == expected_program_id => Ok(()),
366        Some(_) | None => Err(ProgramError::InvalidAccountData),
367    }
368}
369
370// ── Tests ────────────────────────────────────────────────────────────────────
371
372#[cfg(test)]
373mod tests {
374    extern crate alloc;
375    use super::*;
376    use alloc::vec;
377    use alloc::vec::Vec;
378
379    /// Build a mint buffer in the **real** Token-2022 on-chain layout:
380    /// 82 bytes of `Mint` base, 83 bytes of zero padding equalizing it
381    /// to the token-account length, one `AccountType` byte, then TLV
382    /// entries. An earlier iteration of this helper elided the 83-byte
383    /// padding region, and the parser was wrong in exactly the
384    /// complementary way, so tests agreed with buggy code. This helper
385    /// now matches `spl-token-2022` and pinocchio's
386    /// `validate_account_type` (AccountType at offset 165, TLV at 166).
387    fn sample_mint_with_extensions(exts: &[(u16, &[u8])]) -> Vec<u8> {
388        let mut data = vec![0u8; ACCOUNT_TYPE_OFFSET]; // 82 base + 83 padding
389        data.push(ACCOUNT_TYPE_MINT);
390        for (ext_type, ext_value) in exts {
391            data.extend_from_slice(&ext_type.to_le_bytes());
392            data.extend_from_slice(&(ext_value.len() as u16).to_le_bytes());
393            data.extend_from_slice(ext_value);
394        }
395        debug_assert!(data.len() > TLV_OFFSET);
396        data
397    }
398
399    /// Single-extension convenience wrapper.
400    fn sample_mint_with_extension(ext_type: u16, ext_value: &[u8]) -> Vec<u8> {
401        sample_mint_with_extensions(&[(ext_type, ext_value)])
402    }
403
404    // ── Layout invariants (regression for the offset bug) ────────────────
405
406    #[test]
407    fn offset_constants_match_authoritative_spec() {
408        assert_eq!(MINT_BASE_SIZE, 82);
409        assert_eq!(TOKEN_ACCOUNT_BASE_SIZE, 165);
410        assert_eq!(ACCOUNT_TYPE_OFFSET, 165);
411        assert_eq!(TLV_OFFSET, 166);
412        assert_eq!(ACCOUNT_TYPE_MINT, 1);
413        assert_eq!(ACCOUNT_TYPE_TOKEN, 2);
414    }
415
416    #[test]
417    fn tlv_payload_lives_at_byte_166() {
418        // Construct a real-layout mint with a single NonTransferable
419        // extension. The first byte of the TLV header must sit at
420        // offset 166, not offset 84 (the prior buggy offset).
421        let data = sample_mint_with_extension(EXT_NON_TRANSFERABLE, &[]);
422        assert_eq!(
423            u16::from_le_bytes([data[TLV_OFFSET], data[TLV_OFFSET + 1]]),
424            EXT_NON_TRANSFERABLE,
425        );
426        // And the previously-expected offset is pure zero padding.
427        assert_eq!(data[84], 0);
428        assert_eq!(data[85], 0);
429    }
430
431    // ── Screening checks ─────────────────────────────────────────────────
432
433    #[test]
434    fn no_extensions_passes_all_checks() {
435        // Plain, non-extended mint (exactly 82 bytes).
436        let data = vec![0u8; MINT_BASE_SIZE];
437        assert!(check_safe_token_2022_mint(&data).is_ok());
438    }
439
440    #[test]
441    fn detects_transfer_fee() {
442        let data = sample_mint_with_extension(EXT_TRANSFER_FEE_CONFIG, &[0u8; 108]);
443        assert!(mint_has_extension(&data, EXT_TRANSFER_FEE_CONFIG));
444        assert!(check_no_transfer_fee(&data).is_err());
445        assert!(check_safe_token_2022_mint(&data).is_err());
446    }
447
448    #[test]
449    fn detects_permanent_delegate() {
450        let data = sample_mint_with_extension(EXT_PERMANENT_DELEGATE, &[0u8; 32]);
451        assert!(check_no_permanent_delegate(&data).is_err());
452        assert!(check_safe_token_2022_mint(&data).is_err());
453    }
454
455    #[test]
456    fn detects_confidential_transfer() {
457        let data = sample_mint_with_extension(EXT_CONFIDENTIAL_TRANSFER_MINT, &[0u8; 64]);
458        assert!(check_no_confidential_transfer(&data).is_err());
459    }
460
461    #[test]
462    fn detects_non_transferable() {
463        let data = sample_mint_with_extension(EXT_NON_TRANSFERABLE, &[]);
464        assert!(check_transferable(&data).is_err());
465    }
466
467    #[test]
468    fn detects_transfer_hook() {
469        let data = sample_mint_with_extension(EXT_TRANSFER_HOOK, &[0u8; 64]);
470        assert!(check_no_transfer_hook(&data).is_err());
471    }
472
473    #[test]
474    fn safe_with_benign_extensions_only() {
475        // Metadata pointer + token metadata = benign, should pass.
476        let data = sample_mint_with_extensions(&[
477            (EXT_METADATA_POINTER, &[0u8; 64]),
478            (EXT_TOKEN_METADATA, &[0u8; 100]),
479        ]);
480        assert!(check_safe_token_2022_mint(&data).is_ok());
481    }
482
483    #[test]
484    fn finds_second_extension() {
485        let data = sample_mint_with_extensions(&[
486            (EXT_METADATA_POINTER, &[0u8; 64]),
487            (EXT_PERMANENT_DELEGATE, &[0u8; 32]),
488        ]);
489        assert!(mint_has_extension(&data, EXT_PERMANENT_DELEGATE));
490        assert!(check_no_permanent_delegate(&data).is_err());
491    }
492
493    #[test]
494    fn read_transfer_fee_config_parses_correctly() {
495        let mut ext_value = vec![0u8; 96];
496        // newer_maximum_fee at offset 86..94 = 1_000_000
497        let max_fee = 1_000_000u64;
498        ext_value[86..94].copy_from_slice(&max_fee.to_le_bytes());
499        // newer_transfer_fee_bps at offset 94..96 = 250 (2.5%)
500        ext_value[94..96].copy_from_slice(&250u16.to_le_bytes());
501
502        let data = sample_mint_with_extension(EXT_TRANSFER_FEE_CONFIG, &ext_value);
503        let fee = read_transfer_fee_config(&data).unwrap();
504        assert_eq!(fee.fee_bps, 250);
505        assert_eq!(fee.maximum_fee, 1_000_000);
506    }
507
508    #[test]
509    fn read_transfer_fee_config_rejects_missing() {
510        let data = vec![0u8; MINT_BASE_SIZE];
511        assert!(read_transfer_fee_config(&data).is_err());
512    }
513
514    #[test]
515    fn truncated_tlv_returns_none() {
516        let mut data = vec![0u8; ACCOUNT_TYPE_OFFSET];
517        data.push(ACCOUNT_TYPE_MINT);
518        // Write type but length points past end.
519        data.extend_from_slice(&EXT_TRANSFER_FEE_CONFIG.to_le_bytes());
520        data.extend_from_slice(&200u16.to_le_bytes()); // claims 200 bytes
521        data.extend_from_slice(&[0u8; 10]); // only add 10
522        assert!(!mint_has_extension(&data, EXT_TRANSFER_FEE_CONFIG));
523    }
524
525    #[test]
526    fn rejects_reading_mint_extension_out_of_token_account() {
527        // A real extended token account should not be treated as a
528        // mint just because the caller passed the wrong base_size.
529        // Historically this was silently wrong; now find_extension_data
530        // must refuse the AccountType mismatch.
531        let mut data = vec![0u8; ACCOUNT_TYPE_OFFSET];
532        data.push(ACCOUNT_TYPE_TOKEN);
533        data.extend_from_slice(&EXT_TRANSFER_FEE_AMOUNT.to_le_bytes());
534        data.extend_from_slice(&0u16.to_le_bytes());
535        // Calling with MINT_BASE_SIZE must fail regardless of contents.
536        assert!(find_extension_data(&data, MINT_BASE_SIZE, EXT_TRANSFER_FEE_AMOUNT).is_none());
537        // And the token-account path must find it.
538        assert!(
539            find_extension_data(&data, TOKEN_ACCOUNT_BASE_SIZE, EXT_TRANSFER_FEE_AMOUNT).is_some()
540        );
541    }
542
543    #[test]
544    fn unknown_base_size_is_rejected() {
545        // The old implementation accepted arbitrary base_size values
546        // and walked attacker-controlled pointer arithmetic. The new
547        // implementation refuses anything that is not one of the two
548        // canonical shapes.
549        let data = sample_mint_with_extension(EXT_NON_TRANSFERABLE, &[]);
550        assert!(find_extension_data(&data, 42, EXT_NON_TRANSFERABLE).is_none());
551        assert!(find_extension_data(&data, 0, EXT_NON_TRANSFERABLE).is_none());
552    }
553
554    // ── Transfer Hook reader (R6) ────────────────────────────────────────
555
556    #[test]
557    fn read_transfer_hook_parses_authority_and_program_id() {
558        let mut ext_value = vec![0u8; 64];
559        // authority = 0xAA... (32 bytes)
560        for i in 0..32 {
561            ext_value[i] = 0xAA;
562        }
563        // program_id = 0xBB... (32 bytes)
564        for i in 32..64 {
565            ext_value[i] = 0xBB;
566        }
567        let data = sample_mint_with_extension(EXT_TRANSFER_HOOK, &ext_value);
568        let hook = read_transfer_hook(&data).unwrap().unwrap();
569        assert_eq!(hook.authority, &[0xAA; 32]);
570        assert_eq!(hook.program_id, &[0xBB; 32]);
571    }
572
573    #[test]
574    fn read_transfer_hook_returns_none_when_absent() {
575        // Plain mint with no extensions.
576        let data = vec![0u8; MINT_BASE_SIZE];
577        assert!(matches!(read_transfer_hook(&data), Ok(None)));
578    }
579
580    #[test]
581    fn read_transfer_hook_rejects_truncated_extension() {
582        // Hook extension with only 32 bytes of payload - enough for
583        // authority but not program_id. Must be rejected.
584        let data = sample_mint_with_extension(EXT_TRANSFER_HOOK, &[0u8; 32]);
585        assert!(read_transfer_hook(&data).is_err());
586    }
587
588    #[test]
589    fn check_transfer_hook_program_accepts_match() {
590        let mut ext_value = vec![0u8; 64];
591        ext_value[32..64].copy_from_slice(&[0xCC; 32]);
592        let data = sample_mint_with_extension(EXT_TRANSFER_HOOK, &ext_value);
593        assert!(check_transfer_hook_program(&data, &[0xCC; 32]).is_ok());
594    }
595
596    #[test]
597    fn check_transfer_hook_program_rejects_mismatch() {
598        let mut ext_value = vec![0u8; 64];
599        ext_value[32..64].copy_from_slice(&[0xCC; 32]);
600        let data = sample_mint_with_extension(EXT_TRANSFER_HOOK, &ext_value);
601        assert!(check_transfer_hook_program(&data, &[0xDD; 32]).is_err());
602    }
603
604    #[test]
605    fn check_transfer_hook_program_rejects_missing_extension() {
606        let data = vec![0u8; MINT_BASE_SIZE];
607        assert!(check_transfer_hook_program(&data, &[0; 32]).is_err());
608    }
609}