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 deliberately supports a bounded set of fixed-size mint
6//! extensions; variable-length metadata and confidential extensions are not
7//! inferred or initialized automatically.
8
9use crate::instruction::{InstructionAccount, InstructionView, Signer};
10use crate::{AccountView, Address, ProgramError, ProgramResult};
11
12/// Token-2022 program address.
13pub const TOKEN_2022_PROGRAM_ID: Address = Address::new_from_array(crate::__decode_base58_32(
14    "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb",
15));
16
17/// The two token programs supported by mint initialization.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum MintProgram {
20    Legacy,
21    Token2022,
22}
23
24impl MintProgram {
25    pub const fn address(self) -> &'static Address {
26        match self {
27            Self::Legacy => &crate::token::TOKEN_PROGRAM_ID,
28            Self::Token2022 => &TOKEN_2022_PROGRAM_ID,
29        }
30    }
31}
32
33/// Base mint configuration. Initializing a mint does not mint any supply.
34#[derive(Clone, Copy)]
35pub struct MintConfig<'a> {
36    pub decimals: u8,
37    pub mint_authority: &'a Address,
38    pub freeze_authority: Option<&'a Address>,
39}
40
41/// Fixed-size mint extensions supported by [`MintPlan`].
42///
43/// Extension authorities and hook/metadata addresses use Token-2022's nullable
44/// address encoding where applicable. `Some(zero_address)` is rejected for
45/// those fields, rather than silently being interpreted as `None`.
46#[derive(Clone, Copy)]
47pub enum MintExtension<'a> {
48    TransferFeeConfig {
49        authority: Option<&'a Address>,
50        withdraw_authority: Option<&'a Address>,
51        basis_points: u16,
52        maximum_fee: u64,
53    },
54    MintCloseAuthority(Option<&'a Address>),
55    NonTransferable,
56    PermanentDelegate(&'a Address),
57    TransferHook {
58        authority: Option<&'a Address>,
59        program_id: Option<&'a Address>,
60    },
61    MetadataPointer {
62        authority: Option<&'a Address>,
63        metadata_address: Option<&'a Address>,
64    },
65}
66
67impl MintExtension<'_> {
68    /// Token-2022's TLV extension discriminator (not an instruction tag).
69    pub const fn extension_type(&self) -> u16 {
70        match self {
71            Self::TransferFeeConfig { .. } => 1,
72            Self::MintCloseAuthority(_) => 3,
73            Self::NonTransferable => 9,
74            Self::PermanentDelegate(_) => 12,
75            Self::TransferHook { .. } => 14,
76            Self::MetadataPointer { .. } => 18,
77        }
78    }
79
80    const fn value_len(&self) -> usize {
81        match self {
82            Self::TransferFeeConfig { .. } => 108,
83            Self::MintCloseAuthority(_) | Self::PermanentDelegate(_) => 32,
84            Self::NonTransferable => 0,
85            Self::TransferHook { .. } | Self::MetadataPointer { .. } => 64,
86        }
87    }
88
89    fn validate(&self) -> ProgramResult {
90        fn nullable(value: Option<&Address>) -> ProgramResult {
91            if value.is_some_and(|a| a.as_array() == &[0; 32]) {
92                Err(ProgramError::InvalidArgument)
93            } else {
94                Ok(())
95            }
96        }
97        match *self {
98            Self::TransferFeeConfig {
99                authority,
100                withdraw_authority,
101                basis_points,
102                ..
103            } => {
104                nullable(authority)?;
105                nullable(withdraw_authority)?;
106                if basis_points > 10_000 {
107                    return Err(ProgramError::InvalidArgument);
108                }
109            }
110            Self::MintCloseAuthority(authority) => nullable(authority)?,
111            Self::PermanentDelegate(delegate) => nullable(Some(delegate))?,
112            Self::TransferHook {
113                authority,
114                program_id,
115            } => {
116                nullable(authority)?;
117                nullable(program_id)?;
118            }
119            Self::MetadataPointer {
120                authority,
121                metadata_address,
122            } => {
123                nullable(authority)?;
124                nullable(metadata_address)?;
125            }
126            Self::NonTransferable => {}
127        }
128        Ok(())
129    }
130
131    /// Canonical instruction bytes, also usable by off-chain instruction builders.
132    pub fn instruction_data(&self) -> Result<MintInstructionData, ProgramError> {
133        self.validate()?;
134        let mut out = MintInstructionData::new();
135        match *self {
136            Self::TransferFeeConfig {
137                authority,
138                withdraw_authority,
139                basis_points,
140                maximum_fee,
141            } => {
142                out.push(&[26, 0]);
143                out.option(authority);
144                out.option(withdraw_authority);
145                out.push(&basis_points.to_le_bytes());
146                out.push(&maximum_fee.to_le_bytes());
147            }
148            Self::MintCloseAuthority(authority) => {
149                out.push(&[25]);
150                out.option(authority);
151            }
152            Self::NonTransferable => out.push(&[32]),
153            Self::PermanentDelegate(delegate) => {
154                out.push(&[35]);
155                out.push(delegate.as_array());
156            }
157            Self::TransferHook {
158                authority,
159                program_id,
160            } => {
161                out.push(&[36, 0]);
162                out.nullable(authority);
163                out.nullable(program_id);
164            }
165            Self::MetadataPointer {
166                authority,
167                metadata_address,
168            } => {
169                out.push(&[39, 0]);
170                out.nullable(authority);
171                out.nullable(metadata_address);
172            }
173        }
174        Ok(out)
175    }
176}
177
178/// Stack-backed canonical mint instruction data, bounded at 78 bytes.
179pub struct MintInstructionData {
180    bytes: [u8; 78],
181    len: usize,
182}
183
184impl MintInstructionData {
185    fn new() -> Self {
186        Self {
187            bytes: [0; 78],
188            len: 0,
189        }
190    }
191    fn push(&mut self, bytes: &[u8]) {
192        self.bytes[self.len..self.len + bytes.len()].copy_from_slice(bytes);
193        self.len += bytes.len();
194    }
195    fn option(&mut self, address: Option<&Address>) {
196        self.push(&[u8::from(address.is_some())]);
197        if let Some(address) = address {
198            self.push(address.as_array());
199        }
200    }
201    fn nullable(&mut self, address: Option<&Address>) {
202        self.push(address.map_or(&[0; 32][..], |a| &a.as_array()[..]));
203    }
204    pub fn as_bytes(&self) -> &[u8] {
205        &self.bytes[..self.len]
206    }
207}
208
209impl MintConfig<'_> {
210    /// Encode `InitializeMint2` (no Rent sysvar account is required).
211    pub fn instruction_data(&self) -> MintInstructionData {
212        let mut out = MintInstructionData::new();
213        out.push(&[20, self.decimals]);
214        out.push(self.mint_authority.as_array());
215        out.option(self.freeze_authority);
216        out
217    }
218}
219
220/// Low-level `InitializeMint2` CPI builder for either token program.
221///
222/// The token processor checks allocation, rent, extension compatibility and
223/// initialization state. Use [`MintPlan`] to also bind allocation and extension
224/// initialization together. The mint must have been allocated and assigned to
225/// the selected token program already.
226pub struct InitializeMint2<'a, 'b> {
227    pub mint: &'a AccountView<'a>,
228    pub program: MintProgram,
229    pub config: MintConfig<'b>,
230}
231
232impl InitializeMint2<'_, '_> {
233    pub fn invoke(&self) -> ProgramResult {
234        invoke_mint(
235            self.mint,
236            self.program,
237            self.config.instruction_data().as_bytes(),
238        )
239    }
240}
241
242fn invoke_mint(mint: &AccountView<'_>, program: MintProgram, data: &[u8]) -> ProgramResult {
243    if !mint.owned_by(program.address()) {
244        return Err(ProgramError::IncorrectProgramId);
245    }
246    if !mint.is_writable() {
247        return Err(ProgramError::InvalidArgument);
248    }
249    let accounts = [InstructionAccount::writable(mint.address())];
250    let instruction = InstructionView {
251        program_id: program.address(),
252        accounts: &accounts,
253        data,
254    };
255    crate::cpi::invoke(&instruction, &[mint])
256}
257
258/// Validated, allocation-free mint creation plan.
259///
260/// `new` rejects duplicate extensions, invalid nullable addresses and fee rates,
261/// and extensions on the legacy program. `space` includes Token-2022 padding
262/// and TLV headers. There is no arbitrary spare capacity: Token-2022 requires
263/// the allocation to equal the initialized extension set's canonical size.
264pub struct MintPlan<'a> {
265    program: MintProgram,
266    config: MintConfig<'a>,
267    extensions: &'a [MintExtension<'a>],
268    space: usize,
269}
270
271impl<'a> MintPlan<'a> {
272    pub fn new(
273        program: MintProgram,
274        config: MintConfig<'a>,
275        extensions: &'a [MintExtension<'a>],
276    ) -> Result<Self, ProgramError> {
277        if program == MintProgram::Legacy && !extensions.is_empty() {
278            return Err(ProgramError::InvalidArgument);
279        }
280        let mut seen = 0u32;
281        let mut space = if extensions.is_empty() { 82 } else { 166 };
282        for extension in extensions {
283            extension.validate()?;
284            let mask = 1u32 << extension.extension_type();
285            if seen & mask != 0 {
286                return Err(ProgramError::InvalidArgument);
287            }
288            seen |= mask;
289            space += 4 + extension.value_len();
290        }
291        // Token-2022 reserves the legacy multisig size and pads past it.
292        if space == 355 {
293            space += 2;
294        }
295        Ok(Self {
296            program,
297            config,
298            extensions,
299            space,
300        })
301    }
302
303    pub const fn space(&self) -> usize {
304        self.space
305    }
306
307    /// Check an explicitly supplied allocation before paying rent or invoking CPI.
308    pub fn check_space(&self, space: usize) -> ProgramResult {
309        if space != self.space {
310            return Err(ProgramError::InvalidAccountData);
311        }
312        Ok(())
313    }
314
315    /// Initialize an already allocated, rent-exempt, entirely zeroed mint.
316    /// Include the selected executable token program in the outer instruction.
317    /// Propagate errors: if a later CPI fails, the enclosing instruction must
318    /// fail to roll back earlier extension initialization.
319    pub fn initialize(&self, mint: &AccountView<'_>) -> ProgramResult {
320        if !mint.owned_by(self.program.address()) {
321            return Err(ProgramError::IncorrectProgramId);
322        }
323        if !mint.is_writable() {
324            return Err(ProgramError::InvalidArgument);
325        }
326        self.check_space(mint.data_len())?;
327        if mint.lamports() < crate::rent::minimum_balance_live(self.space)? {
328            return Err(ProgramError::AccountNotRentExempt);
329        }
330        {
331            let data = mint.try_borrow()?;
332            if data.iter().any(|b| *b != 0) {
333                return Err(ProgramError::AccountAlreadyInitialized);
334            }
335        }
336        for extension in self.extensions {
337            invoke_mint(mint, self.program, extension.instruction_data()?.as_bytes())?;
338        }
339        InitializeMint2 {
340            mint,
341            program: self.program,
342            config: self.config,
343        }
344        .invoke()
345    }
346
347    /// Allocate a fresh System-owned mint, initialize extensions, then the base
348    /// mint. Prefunding is supported; only the live rent shortfall is charged.
349    ///
350    /// Include the System and selected token programs in the outer instruction.
351    /// The mint and payer must sign, directly or through `signers`. As with any
352    /// sequence of CPIs, propagate errors to preserve transaction atomicity.
353    /// Uses System `CreateAccountAllowPrefund`; the target cluster must enable it.
354    pub fn create(
355        &self,
356        payer: &AccountView<'_>,
357        mint: &AccountView<'_>,
358        signers: &[Signer<'_, '_>],
359    ) -> ProgramResult {
360        if payer.address() == mint.address() || !payer.is_writable() || !mint.is_writable() {
361            return Err(ProgramError::InvalidArgument);
362        }
363        if !mint.owned_by(&crate::system::SYSTEM_PROGRAM_ID)
364            || !payer.owned_by(&crate::system::SYSTEM_PROGRAM_ID)
365        {
366            return Err(ProgramError::IncorrectProgramId);
367        }
368        if mint.data_len() != 0 || payer.data_len() != 0 {
369            return Err(ProgramError::InvalidAccountData);
370        }
371        let rent = crate::rent::minimum_balance_live(self.space)?;
372        let funding = rent.saturating_sub(mint.lamports());
373        if payer.lamports() < funding {
374            return Err(ProgramError::InsufficientFunds);
375        }
376        crate::system::CreateAccountAllowPrefund {
377            to: mint,
378            funding: Some((payer, funding)),
379            space: self.space as u64,
380            owner: self.program.address(),
381        }
382        .invoke_signed(signers)?;
383        self.initialize(mint)
384    }
385}