Skip to main content

hopper_solana/
interface.rs

1//! `InterfaceAccount` / `InterfaceMint` - Token + Token-2022 polymorphism.
2//!
3//! Quasar's headline DX win is `InterfaceAccount<Token>`: a single wrapper
4//! that accepts an account owned by either the SPL Token program or the
5//! Token-2022 program, performs one owner check at parse time, and
6//! exposes the unified mint / owner / amount / state surface.
7//!
8//! Hopper exposes the equivalent shape here:
9//!
10//! - [`InterfaceTokenAccount`] - token-account-shaped overlay for either
11//!   SPL Token or Token-2022.
12//! - [`InterfaceMint`] - mint-shaped overlay for either program.
13//! - [`TokenProgramKind`] - discriminates which program owns the account.
14//!
15//! The first 165 bytes of an SPL Token Account and the first 165 bytes
16//! of a Token-2022 token account share the same on-disk layout (mint,
17//! owner, amount, delegate, state, …), so the existing zero-copy
18//! readers in [`crate::token`] and [`crate::mint`] work for both. This
19//! module adds the validation gate (owner ∈ {Token, Token-2022}) plus
20//! a polymorphic `transfer_checked` CPI helper that dispatches to the
21//! correct program.
22
23use hopper_runtime::account::AccountView;
24use hopper_runtime::address::Address;
25use hopper_runtime::error::ProgramError;
26use hopper_runtime::instruction::{InstructionAccount, InstructionView, Signer};
27use hopper_runtime::ProgramResult;
28
29use crate::constants::{TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID};
30
31/// Which token program owns this account.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum TokenProgramKind {
34    /// SPL Token (`TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA`).
35    Spl,
36    /// SPL Token-2022 (`TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb`).
37    Token2022,
38}
39
40impl TokenProgramKind {
41    /// Resolve the program-id address backing this kind.
42    #[inline(always)]
43    pub const fn program_id(self) -> &'static Address {
44        match self {
45            TokenProgramKind::Spl => &TOKEN_PROGRAM_ID,
46            TokenProgramKind::Token2022 => &TOKEN_2022_PROGRAM_ID,
47        }
48    }
49
50    /// Match an account's owner against the two supported programs.
51    ///
52    /// Returns `Err(IncorrectProgramId)` if the account is owned by
53    /// any other program.
54    #[inline(always)]
55    pub fn from_owner(owner: &Address) -> Result<Self, ProgramError> {
56        if owner == &TOKEN_PROGRAM_ID {
57            Ok(TokenProgramKind::Spl)
58        } else if owner == &TOKEN_2022_PROGRAM_ID {
59            Ok(TokenProgramKind::Token2022)
60        } else {
61            Err(ProgramError::IncorrectProgramId)
62        }
63    }
64
65    /// Resolve the kind from an [`AccountView`] using its owner.
66    ///
67    /// Wrapper over [`AccountView::owned_by`] that stays on the safe
68    /// (no-unsafe) side of the runtime API surface.
69    #[inline(always)]
70    pub fn for_account(view: &AccountView) -> Result<Self, ProgramError> {
71        if view.owned_by(&TOKEN_PROGRAM_ID) {
72            Ok(TokenProgramKind::Spl)
73        } else if view.owned_by(&TOKEN_2022_PROGRAM_ID) {
74            Ok(TokenProgramKind::Token2022)
75        } else {
76            Err(ProgramError::IncorrectProgramId)
77        }
78    }
79}
80
81/// Polymorphic SPL Token / Token-2022 token-account overlay.
82///
83/// Construct via [`InterfaceTokenAccount::from_data`] using a borrowed
84/// view of an account body that has already been ownership-checked
85/// via [`TokenProgramKind::for_account`]. The constructor validates
86/// the body is at least [`crate::token::TOKEN_ACCOUNT_LEN`] (165) bytes.
87///
88/// The reader methods delegate to [`crate::token`], which is correct
89/// for both programs because the first 165 bytes of a Token-2022
90/// account match the SPL Token layout exactly.
91///
92/// ```ignore
93/// let kind = TokenProgramKind::for_account(&view)?;
94/// let data = view.try_borrow()?;
95/// let token = InterfaceTokenAccount::from_data(&data, kind)?;
96/// let mint = token.mint()?;
97/// ```
98#[derive(Debug, Clone, Copy)]
99pub struct InterfaceTokenAccount<'a> {
100    /// Raw account body. Always at least 165 bytes.
101    data: &'a [u8],
102    /// Which program owns the account.
103    pub kind: TokenProgramKind,
104}
105
106impl<'a> InterfaceTokenAccount<'a> {
107    /// Wrap a previously-borrowed account body.
108    ///
109    /// Caller is responsible for confirming `kind` matches the
110    /// account's actual owner - usually by calling
111    /// [`TokenProgramKind::for_account`] beforehand.
112    pub fn from_data(data: &'a [u8], kind: TokenProgramKind) -> Result<Self, ProgramError> {
113        if data.len() < crate::token::TOKEN_ACCOUNT_LEN {
114            return Err(ProgramError::InvalidAccountData);
115        }
116        Ok(Self { data, kind })
117    }
118
119    /// The raw account body. Always at least 165 bytes.
120    #[inline(always)]
121    pub fn data(&self) -> &'a [u8] {
122        self.data
123    }
124
125    /// The mint pubkey.
126    #[inline(always)]
127    pub fn mint(&self) -> Result<&'a Address, ProgramError> {
128        crate::token::token_account_mint(self.data)
129    }
130
131    /// The owner pubkey of the token account (the user wallet, not the
132    /// program owning the account).
133    #[inline(always)]
134    pub fn owner(&self) -> Result<&'a Address, ProgramError> {
135        crate::token::token_account_owner(self.data)
136    }
137
138    /// The token amount.
139    #[inline(always)]
140    pub fn amount(&self) -> Result<u64, ProgramError> {
141        crate::token::token_account_amount(self.data)
142    }
143
144    /// The state byte (`0` = uninitialised, `1` = initialised, `2` = frozen).
145    #[inline(always)]
146    pub fn state(&self) -> Result<u8, ProgramError> {
147        crate::token::token_account_state(self.data)
148    }
149
150    /// Convenience: assert the account is initialised.
151    #[inline(always)]
152    pub fn assert_initialized(&self) -> Result<(), ProgramError> {
153        crate::token::check_token_initialized(self.data)
154    }
155
156    /// Convenience: assert the wallet owner matches.
157    #[inline(always)]
158    pub fn assert_owner(&self, expected: &Address) -> Result<(), ProgramError> {
159        crate::token::check_token_owner(self.data, expected)
160    }
161
162    /// Convenience: assert the mint matches.
163    #[inline(always)]
164    pub fn assert_mint(&self, expected: &Address) -> Result<(), ProgramError> {
165        crate::token::check_token_mint(self.data, expected)
166    }
167}
168
169/// Polymorphic SPL Token / Token-2022 mint overlay.
170///
171/// SPL Mint and Token-2022 base mint share the same first 82 bytes
172/// (mint authority COption, supply, decimals, is_init flag, freeze
173/// authority COption). Token-2022 extension bytes begin at offset
174/// 165; this wrapper exposes only the base layout. Use
175/// [`crate::token2022_ext`] helpers for extension parsing.
176#[derive(Debug, Clone, Copy)]
177pub struct InterfaceMint<'a> {
178    data: &'a [u8],
179    /// Which program owns the mint.
180    pub kind: TokenProgramKind,
181}
182
183impl<'a> InterfaceMint<'a> {
184    /// Wrap a previously-borrowed mint body. Caller verifies `kind`
185    /// using [`TokenProgramKind::for_account`].
186    pub fn from_data(data: &'a [u8], kind: TokenProgramKind) -> Result<Self, ProgramError> {
187        if data.len() < crate::mint::MINT_LEN {
188            return Err(ProgramError::InvalidAccountData);
189        }
190        Ok(Self { data, kind })
191    }
192
193    /// The raw mint bytes.
194    #[inline(always)]
195    pub fn data(&self) -> &'a [u8] {
196        self.data
197    }
198
199    /// The mint supply.
200    #[inline(always)]
201    pub fn supply(&self) -> Result<u64, ProgramError> {
202        crate::mint::mint_supply(self.data)
203    }
204
205    /// The mint decimals.
206    #[inline(always)]
207    pub fn decimals(&self) -> Result<u8, ProgramError> {
208        crate::mint::mint_decimals(self.data)
209    }
210
211    /// The mint authority, if set.
212    #[inline(always)]
213    pub fn authority(&self) -> Result<Option<&'a Address>, ProgramError> {
214        crate::mint::mint_authority(self.data)
215    }
216
217    /// The freeze authority, if set.
218    #[inline(always)]
219    pub fn freeze_authority(&self) -> Result<Option<&'a Address>, ProgramError> {
220        crate::mint::mint_freeze_authority(self.data)
221    }
222
223    /// Convenience: assert the mint is initialised.
224    #[inline(always)]
225    pub fn assert_initialized(&self) -> Result<(), ProgramError> {
226        crate::mint::check_mint_initialized(self.data)
227    }
228}
229
230// ── Polymorphic CPI helpers ──────────────────────────────────────────
231
232/// Polymorphic `TransferChecked` CPI that dispatches to the program
233/// that owns the source token account.
234///
235/// The instruction layout is shared between SPL Token and Token-2022:
236/// `[12u8, amount: u64 LE, decimals: u8]` with three accounts (source,
237/// mint, destination, authority). This helper picks the right program
238/// id based on the source account's owner and forwards through the
239/// runtime's checked CPI path.
240#[inline]
241pub fn interface_transfer_checked<'a>(
242    source: &'a AccountView,
243    mint: &'a AccountView,
244    destination: &'a AccountView,
245    authority: &'a AccountView,
246    amount: u64,
247    decimals: u8,
248) -> ProgramResult {
249    interface_transfer_checked_signed(source, mint, destination, authority, amount, decimals, &[])
250}
251
252/// PDA-signing variant of [`interface_transfer_checked`].
253#[inline]
254pub fn interface_transfer_checked_signed<'a>(
255    source: &'a AccountView,
256    mint: &'a AccountView,
257    destination: &'a AccountView,
258    authority: &'a AccountView,
259    amount: u64,
260    decimals: u8,
261    signers: &[Signer],
262) -> ProgramResult {
263    let kind = TokenProgramKind::for_account(source)?;
264
265    let mut data = [0u8; 10];
266    data[0] = 12; // TransferChecked discriminator (shared between Token and Token-2022)
267    data[1..9].copy_from_slice(&amount.to_le_bytes());
268    data[9] = decimals;
269
270    let accounts = [
271        InstructionAccount::writable(source.address()),
272        InstructionAccount::readonly(mint.address()),
273        InstructionAccount::writable(destination.address()),
274        InstructionAccount::readonly_signer(authority.address()),
275    ];
276    let views = [source, mint, destination, authority];
277    let instruction = InstructionView {
278        program_id: kind.program_id(),
279        data: &data,
280        accounts: &accounts,
281    };
282
283    hopper_runtime::cpi::invoke_signed(&instruction, &views, signers)
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn token_program_kind_from_owner_matches_known_programs() {
292        assert_eq!(
293            TokenProgramKind::from_owner(&TOKEN_PROGRAM_ID).unwrap(),
294            TokenProgramKind::Spl,
295        );
296        assert_eq!(
297            TokenProgramKind::from_owner(&TOKEN_2022_PROGRAM_ID).unwrap(),
298            TokenProgramKind::Token2022,
299        );
300    }
301
302    #[test]
303    fn token_program_kind_from_owner_rejects_other_programs() {
304        let other = Address::new_from_array([7u8; 32]);
305        assert!(matches!(
306            TokenProgramKind::from_owner(&other),
307            Err(ProgramError::IncorrectProgramId),
308        ));
309    }
310
311    #[test]
312    fn token_program_kind_program_id_is_stable() {
313        assert_eq!(TokenProgramKind::Spl.program_id(), &TOKEN_PROGRAM_ID,);
314        assert_eq!(
315            TokenProgramKind::Token2022.program_id(),
316            &TOKEN_2022_PROGRAM_ID,
317        );
318    }
319}