Skip to main content

hopper_runtime/
token_mint.rs

1//! Allocation and initialization of legacy SPL and Token-2022 mints.
2//!
3//! A [`MintPlan`] ties the exact allocation to the extension initializers it
4//! will execute. It uses no heap allocation and initializes extensions before
5//! the base mint. It supports the thirteen fixed-size mint extensions;
6//! variable-length metadata and the confidential extensions are not
7//! inferred or initialized automatically.
8
9use crate::instruction::{InstructionAccount, InstructionView, Signer};
10use crate::token_2022_ix::encoders as ext;
11use crate::{AccountView, Address, ProgramError, ProgramResult};
12
13pub use crate::token::TOKEN_2022_PROGRAM_ID;
14
15/// The token program a mint is created on: the same selector every token
16/// builder takes, under the name mint creation has always used.
17pub type MintProgram = crate::token::TokenProgram;
18
19/// Base mint configuration. Initializing a mint does not mint any supply.
20#[derive(Clone, Copy)]
21pub struct MintConfig<'a> {
22    pub decimals: u8,
23    pub mint_authority: &'a Address,
24    pub freeze_authority: Option<&'a Address>,
25}
26
27/// Fixed-size mint extensions supported by [`MintPlan`].
28///
29/// Extension authorities and hook/metadata addresses use Token-2022's nullable
30/// address encoding where applicable. `Some(zero_address)` is rejected for
31/// those fields, rather than silently being interpreted as `None`.
32///
33/// Token-2022 keeps adding extensions, and each one Hopper supports is a new
34/// variant, so a `match` outside this crate needs a wildcard arm.
35#[derive(Clone, Copy)]
36#[non_exhaustive]
37pub enum MintExtension<'a> {
38    TransferFeeConfig {
39        authority: Option<&'a Address>,
40        withdraw_authority: Option<&'a Address>,
41        basis_points: u16,
42        maximum_fee: u64,
43    },
44    MintCloseAuthority(Option<&'a Address>),
45    NonTransferable,
46    PermanentDelegate(&'a Address),
47    TransferHook {
48        authority: Option<&'a Address>,
49        program_id: Option<&'a Address>,
50    },
51    MetadataPointer {
52        authority: Option<&'a Address>,
53        metadata_address: Option<&'a Address>,
54    },
55    /// New token accounts start in this state: 1 initialized, 2 frozen.
56    DefaultAccountState(u8),
57    /// Balances display with continuously compounding interest at `rate`
58    /// basis points; `rate_authority` may change the rate.
59    InterestBearing {
60        rate_authority: Option<&'a Address>,
61        rate: i16,
62    },
63    /// Balances display multiplied by `multiplier` (positive and finite);
64    /// `authority` may schedule a new multiplier.
65    ScaledUiAmount {
66        authority: Option<&'a Address>,
67        multiplier: f64,
68    },
69    /// The authority that may pause and resume the mint.
70    Pausable(&'a Address),
71    GroupPointer {
72        authority: Option<&'a Address>,
73        group_address: Option<&'a Address>,
74    },
75    GroupMemberPointer {
76        authority: Option<&'a Address>,
77        member_address: Option<&'a Address>,
78    },
79    /// Burns need this authority's signature next to the holder's.
80    PermissionedBurn(&'a Address),
81}
82
83impl MintExtension<'_> {
84    /// Token-2022's TLV extension discriminator (not an instruction tag).
85    pub const fn extension_type(&self) -> u16 {
86        match self {
87            Self::TransferFeeConfig { .. } => 1,
88            Self::MintCloseAuthority(_) => 3,
89            Self::DefaultAccountState(_) => 6,
90            Self::NonTransferable => 9,
91            Self::InterestBearing { .. } => 10,
92            Self::PermanentDelegate(_) => 12,
93            Self::TransferHook { .. } => 14,
94            Self::MetadataPointer { .. } => 18,
95            Self::GroupPointer { .. } => 20,
96            Self::GroupMemberPointer { .. } => 22,
97            Self::ScaledUiAmount { .. } => 25,
98            Self::Pausable(_) => 26,
99            Self::PermissionedBurn(_) => 28,
100        }
101    }
102
103    /// The extension's TLV value length: the size of its on-chain state.
104    pub const fn value_len(&self) -> usize {
105        match self {
106            Self::TransferFeeConfig { .. } => 108,
107            Self::MintCloseAuthority(_)
108            | Self::PermanentDelegate(_)
109            | Self::PermissionedBurn(_) => 32,
110            Self::NonTransferable => 0,
111            Self::TransferHook { .. }
112            | Self::MetadataPointer { .. }
113            | Self::GroupPointer { .. }
114            | Self::GroupMemberPointer { .. } => 64,
115            Self::DefaultAccountState(_) => 1,
116            Self::InterestBearing { .. } => 52,
117            Self::ScaledUiAmount { .. } => 56,
118            Self::Pausable(_) => 33,
119        }
120    }
121
122    fn validate(&self) -> ProgramResult {
123        fn nullable(value: Option<&Address>) -> ProgramResult {
124            if value.is_some_and(|a| a.as_array() == &[0; 32]) {
125                Err(ProgramError::InvalidArgument)
126            } else {
127                Ok(())
128            }
129        }
130        match *self {
131            Self::TransferFeeConfig {
132                authority,
133                withdraw_authority,
134                basis_points,
135                ..
136            } => {
137                nullable(authority)?;
138                nullable(withdraw_authority)?;
139                if basis_points > 10_000 {
140                    return Err(ProgramError::InvalidArgument);
141                }
142            }
143            Self::MintCloseAuthority(authority) => nullable(authority)?,
144            Self::PermanentDelegate(delegate) => nullable(Some(delegate))?,
145            Self::TransferHook {
146                authority,
147                program_id,
148            } => {
149                nullable(authority)?;
150                nullable(program_id)?;
151            }
152            Self::MetadataPointer {
153                authority,
154                metadata_address,
155            } => {
156                nullable(authority)?;
157                nullable(metadata_address)?;
158            }
159            Self::NonTransferable => {}
160            // The Token-2022 encoders validate these shapes themselves.
161            Self::DefaultAccountState(state) => {
162                ext::encode_initialize_default_account_state(state)?;
163            }
164            Self::InterestBearing {
165                rate_authority,
166                rate,
167            } => {
168                ext::encode_initialize_interest_bearing_mint(rate_authority, rate)?;
169            }
170            Self::ScaledUiAmount {
171                authority,
172                multiplier,
173            } => {
174                ext::encode_initialize_scaled_ui_amount(authority, multiplier)?;
175            }
176            Self::Pausable(authority) => {
177                ext::encode_initialize_authority(ext::IX_PAUSABLE, authority)?;
178            }
179            Self::GroupPointer {
180                authority,
181                group_address,
182            } => {
183                ext::encode_initialize_pointer(ext::IX_GROUP_POINTER, authority, group_address)?;
184            }
185            Self::GroupMemberPointer {
186                authority,
187                member_address,
188            } => {
189                ext::encode_initialize_pointer(
190                    ext::IX_GROUP_MEMBER_POINTER,
191                    authority,
192                    member_address,
193                )?;
194            }
195            Self::PermissionedBurn(authority) => {
196                ext::encode_initialize_authority(ext::IX_PERMISSIONED_BURN, authority)?;
197            }
198        }
199        Ok(())
200    }
201
202    /// Canonical instruction bytes, also usable by off-chain instruction builders.
203    pub fn instruction_data(&self) -> Result<MintInstructionData, ProgramError> {
204        self.validate()?;
205        let mut out = MintInstructionData::new();
206        match *self {
207            Self::TransferFeeConfig {
208                authority,
209                withdraw_authority,
210                basis_points,
211                maximum_fee,
212            } => {
213                out.push(&[26, 0]);
214                out.option(authority);
215                out.option(withdraw_authority);
216                out.push(&basis_points.to_le_bytes());
217                out.push(&maximum_fee.to_le_bytes());
218            }
219            Self::MintCloseAuthority(authority) => {
220                out.push(&[25]);
221                out.option(authority);
222            }
223            Self::NonTransferable => out.push(&[32]),
224            Self::PermanentDelegate(delegate) => {
225                out.push(&[35]);
226                out.push(delegate.as_array());
227            }
228            Self::TransferHook {
229                authority,
230                program_id,
231            } => {
232                out.push(&[36, 0]);
233                out.nullable(authority);
234                out.nullable(program_id);
235            }
236            Self::MetadataPointer {
237                authority,
238                metadata_address,
239            } => {
240                out.push(&[39, 0]);
241                out.nullable(authority);
242                out.nullable(metadata_address);
243            }
244            Self::DefaultAccountState(state) => {
245                out.push(&ext::encode_initialize_default_account_state(state)?);
246            }
247            Self::InterestBearing {
248                rate_authority,
249                rate,
250            } => {
251                out.push(
252                    ext::encode_initialize_interest_bearing_mint(rate_authority, rate)?.as_slice(),
253                );
254            }
255            Self::ScaledUiAmount {
256                authority,
257                multiplier,
258            } => {
259                out.push(
260                    ext::encode_initialize_scaled_ui_amount(authority, multiplier)?.as_slice(),
261                );
262            }
263            Self::Pausable(authority) => {
264                out.push(ext::encode_initialize_authority(ext::IX_PAUSABLE, authority)?.as_slice());
265            }
266            Self::GroupPointer {
267                authority,
268                group_address,
269            } => {
270                out.push(
271                    ext::encode_initialize_pointer(
272                        ext::IX_GROUP_POINTER,
273                        authority,
274                        group_address,
275                    )?
276                    .as_slice(),
277                );
278            }
279            Self::GroupMemberPointer {
280                authority,
281                member_address,
282            } => {
283                out.push(
284                    ext::encode_initialize_pointer(
285                        ext::IX_GROUP_MEMBER_POINTER,
286                        authority,
287                        member_address,
288                    )?
289                    .as_slice(),
290                );
291            }
292            Self::PermissionedBurn(authority) => {
293                out.push(
294                    ext::encode_initialize_authority(ext::IX_PERMISSIONED_BURN, authority)?
295                        .as_slice(),
296                );
297            }
298        }
299        Ok(out)
300    }
301}
302
303/// Stack-backed canonical mint instruction data, bounded at 78 bytes.
304pub struct MintInstructionData {
305    bytes: [u8; 78],
306    len: usize,
307}
308
309impl MintInstructionData {
310    fn new() -> Self {
311        Self {
312            bytes: [0; 78],
313            len: 0,
314        }
315    }
316    fn push(&mut self, bytes: &[u8]) {
317        self.bytes[self.len..self.len + bytes.len()].copy_from_slice(bytes);
318        self.len += bytes.len();
319    }
320    fn option(&mut self, address: Option<&Address>) {
321        self.push(&[u8::from(address.is_some())]);
322        if let Some(address) = address {
323            self.push(address.as_array());
324        }
325    }
326    fn nullable(&mut self, address: Option<&Address>) {
327        self.push(address.map_or(&[0; 32][..], |a| &a.as_array()[..]));
328    }
329    pub fn as_bytes(&self) -> &[u8] {
330        &self.bytes[..self.len]
331    }
332}
333
334impl MintConfig<'_> {
335    /// Encode `InitializeMint2` (no Rent sysvar account is required).
336    pub fn instruction_data(&self) -> MintInstructionData {
337        let mut out = MintInstructionData::new();
338        out.push(&[20, self.decimals]);
339        out.push(self.mint_authority.as_array());
340        out.option(self.freeze_authority);
341        out
342    }
343}
344
345/// Low-level `InitializeMint2` CPI builder for either token program.
346///
347/// The token processor checks allocation, rent, extension compatibility and
348/// initialization state. Use [`MintPlan`] to also bind allocation and extension
349/// initialization together. The mint must have been allocated and assigned to
350/// the selected token program already.
351pub struct InitializeMint2<'a, 'b> {
352    pub mint: &'a AccountView<'a>,
353    pub program: MintProgram,
354    pub config: MintConfig<'b>,
355}
356
357impl InitializeMint2<'_, '_> {
358    pub fn invoke(&self) -> ProgramResult {
359        invoke_mint(
360            self.mint,
361            self.program,
362            self.config.instruction_data().as_bytes(),
363        )
364    }
365}
366
367fn invoke_mint(mint: &AccountView<'_>, program: MintProgram, data: &[u8]) -> ProgramResult {
368    if !mint.owned_by(program.address()) {
369        return Err(ProgramError::IncorrectProgramId);
370    }
371    if !mint.is_writable() {
372        return Err(ProgramError::InvalidArgument);
373    }
374    let accounts = [InstructionAccount::writable(mint.address())];
375    let instruction = InstructionView {
376        program_id: program.address(),
377        accounts: &accounts,
378        data,
379    };
380    crate::cpi::invoke(&instruction, &[mint])
381}
382
383/// Validated, allocation-free mint creation plan.
384///
385/// `new` rejects duplicate extensions, invalid nullable addresses and fee rates,
386/// and extensions on the legacy program. `space` includes Token-2022 padding
387/// and TLV headers. There is no arbitrary spare capacity: Token-2022 requires
388/// the allocation to equal the initialized extension set's canonical size.
389pub struct MintPlan<'a> {
390    program: MintProgram,
391    config: MintConfig<'a>,
392    extensions: &'a [MintExtension<'a>],
393    space: usize,
394}
395
396impl<'a> MintPlan<'a> {
397    pub fn new(
398        program: MintProgram,
399        config: MintConfig<'a>,
400        extensions: &'a [MintExtension<'a>],
401    ) -> Result<Self, ProgramError> {
402        if program == MintProgram::Legacy && !extensions.is_empty() {
403            return Err(ProgramError::InvalidArgument);
404        }
405        let mut seen = 0u32;
406        let mut space = if extensions.is_empty() { 82 } else { 166 };
407        for extension in extensions {
408            extension.validate()?;
409            let mask = 1u32 << extension.extension_type();
410            if seen & mask != 0 {
411                return Err(ProgramError::InvalidArgument);
412            }
413            seen |= mask;
414            space += 4 + extension.value_len();
415        }
416        // Token-2022 refuses a mint that displays both interest and a scaled
417        // amount (`InvalidExtensionCombination`); refuse it at plan time.
418        if seen & (1 << 10) != 0 && seen & (1 << 25) != 0 {
419            return Err(ProgramError::InvalidArgument);
420        }
421        // Token-2022 reserves the legacy multisig size and pads past it.
422        if space == 355 {
423            space += 2;
424        }
425        Ok(Self {
426            program,
427            config,
428            extensions,
429            space,
430        })
431    }
432
433    pub const fn space(&self) -> usize {
434        self.space
435    }
436
437    /// Check an explicitly supplied allocation before paying rent or invoking CPI.
438    pub fn check_space(&self, space: usize) -> ProgramResult {
439        if space != self.space {
440            return Err(ProgramError::InvalidAccountData);
441        }
442        Ok(())
443    }
444
445    /// Initialize an already allocated, rent-exempt, entirely zeroed mint.
446    /// Include the selected executable token program in the outer instruction.
447    /// Propagate errors: if a later CPI fails, the enclosing instruction must
448    /// fail to roll back earlier extension initialization.
449    pub fn initialize(&self, mint: &AccountView<'_>) -> ProgramResult {
450        if !mint.owned_by(self.program.address()) {
451            return Err(ProgramError::IncorrectProgramId);
452        }
453        if !mint.is_writable() {
454            return Err(ProgramError::InvalidArgument);
455        }
456        self.check_space(mint.data_len())?;
457        if mint.lamports() < crate::rent::minimum_balance_live(self.space)? {
458            return Err(ProgramError::AccountNotRentExempt);
459        }
460        {
461            let data = mint.try_borrow()?;
462            if data.iter().any(|b| *b != 0) {
463                return Err(ProgramError::AccountAlreadyInitialized);
464            }
465        }
466        for extension in self.extensions {
467            invoke_mint(mint, self.program, extension.instruction_data()?.as_bytes())?;
468        }
469        InitializeMint2 {
470            mint,
471            program: self.program,
472            config: self.config,
473        }
474        .invoke()
475    }
476
477    /// Allocate a fresh System-owned mint, initialize extensions, then the base
478    /// mint. Prefunding is supported; only the live rent shortfall is charged.
479    ///
480    /// Include the System and selected token programs in the outer instruction.
481    /// The mint and payer must sign, directly or through `signers`. As with any
482    /// sequence of CPIs, propagate errors to preserve transaction atomicity.
483    /// Uses System `CreateAccountAllowPrefund`; the target cluster must enable it.
484    pub fn create(
485        &self,
486        payer: &AccountView<'_>,
487        mint: &AccountView<'_>,
488        signers: &[Signer<'_, '_>],
489    ) -> ProgramResult {
490        if payer.address() == mint.address() || !payer.is_writable() || !mint.is_writable() {
491            return Err(ProgramError::InvalidArgument);
492        }
493        if !mint.owned_by(&crate::system::SYSTEM_PROGRAM_ID)
494            || !payer.owned_by(&crate::system::SYSTEM_PROGRAM_ID)
495        {
496            return Err(ProgramError::IncorrectProgramId);
497        }
498        if mint.data_len() != 0 || payer.data_len() != 0 {
499            return Err(ProgramError::InvalidAccountData);
500        }
501        let rent = crate::rent::minimum_balance_live(self.space)?;
502        let funding = rent.saturating_sub(mint.lamports());
503        if payer.lamports() < funding {
504            return Err(ProgramError::InsufficientFunds);
505        }
506        crate::system::CreateAccountAllowPrefund {
507            to: mint,
508            funding: Some((payer, funding)),
509            space: self.space as u64,
510            owner: self.program.address(),
511        }
512        .invoke_signed(signers)?;
513        self.initialize(mint)
514    }
515}