1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
use airdrop_api::Config;
use airdrop_api::{
consts::{MINT, MINT_AUTHORITY},
instruction::CreateMint,
loaders::AirdropAccountInfoValidation,
pda::{mint_authority_pda, mint_pda, mint_treasury_pda},
prelude::*,
};
use mpl_token_metadata::instructions::CreateMetadataAccountV3Cpi;
use solana_program::{
clock::Clock,
program::{invoke, invoke_signed},
program_pack::Pack,
sysvar,
};
use spl_token::{
instruction::{set_authority, AuthorityType},
state::Mint,
};
use steel::*;
/// Process CreateMint instruction
/// Creates ONLY the mint and metadata, NOT a campaign
/// Use CreateCampaign to create campaigns using this mint
///
/// Comparison with miracle-copy:
/// - Miracle-copy: Creates mint + treasury + campaign in one step (Initialize)
/// - Our approach: Separates mint creation from campaign creation (more flexible)
/// - Both use Metaplex Token Metadata (mpl-token-metadata)
/// - Both use PDA-based mint authority
pub fn process_create_mint(accounts: &[AccountInfo<'_>], data: &[u8]) -> ProgramResult {
// Parse instruction data
let args = CreateMint::try_from_bytes(data)?;
let mint_id = args.mint_id;
let noise = args.noise;
let mint_address = args.mint;
let decimals = args.decimals;
let max_supply = u64::from_le_bytes(args.max_supply);
// Validate max_supply (must be > 0)
if max_supply == 0 {
return Err(AirdropError::InvalidAmount.into());
}
// Load accounts
// [payer, config, mint, mint_authority, mint_metadata, mint_treasury, mint_treasury_tokens, fee_account, system_program, token_program, associated_token_program, metadata_program, rent_sysvar]
let [payer_info, config_info, mint_info, mint_authority_info, mint_metadata_info, mint_treasury_info, mint_treasury_tokens_info, fee_account_info, system_program, token_program, associated_token_program, metadata_program, rent_sysvar] =
accounts
else {
return Err(ProgramError::NotEnoughAccountKeys);
};
// Validate accounts
payer_info.is_signer()?;
// Load config (no admin check - anyone can create mints, just pay the fee)
// is_config() validates address + type, as_account() deserializes the struct
let config = config_info
.is_config()?
.as_account::<Config>(&airdrop_api::ID)?;
// Verify fee account early (before checking if mint exists)
fee_account_info
.is_writable()?
.has_address(&config.fee_account)?;
// Determine mint address
// Strategy: Always derive expected PDA from mint_id + noise first
// If mint_address is provided, verify it matches the derived PDA
// - KOL scenario: Frontend can pre-compute mint address from mint_id + noise (KOL wallet bytes)
// - Default scenario: Frontend passes zero, we derive PDA from random mint_id + noise
// - Reuse scenario: Frontend passes existing mint address that matches derived PDA
// - Address grinding: Frontend can grind different noise values offline to find desired pattern
let (expected_mint, mint_bump) = mint_pda(&mint_id, &noise);
let (mint_pubkey, mint_bump) = if mint_address == Pubkey::default() {
// No address provided: use derived PDA
mint_info
.is_empty()?
.is_writable()?
.has_address(&expected_mint)?;
(expected_mint, mint_bump)
} else {
// Address provided: verify it matches derived PDA
if mint_address != expected_mint {
return Err(AirdropError::InvalidMint.into());
}
mint_info.is_writable()?.has_address(&mint_address)?;
// Check if mint already exists
if !mint_info.data_is_empty() {
// Mint exists: verify it's valid and skip creation (no fee for reusing existing mint)
let mint_data = mint_info.as_mint()?;
let (expected_mint_authority, _) = mint_authority_pda(&mint_address);
if mint_data.mint_authority()
!= solana_program::program_option::COption::Some(expected_mint_authority)
{
return Err(AirdropError::InvalidMintAuthority.into());
}
// Mint already exists and is valid, skip creation
// Note: No fee charged for reusing existing mint (intentional)
return Ok(());
}
// Mint doesn't exist but address matches derived PDA: proceed to create
mint_info.is_empty()?;
(mint_address, mint_bump)
};
// Verify mint authority PDA (shared across campaigns using same mint)
let (expected_mint_authority, mint_authority_bump) = mint_authority_pda(&mint_pubkey);
mint_authority_info.has_address(&expected_mint_authority)?;
system_program.is_program(&system_program::ID)?;
token_program.is_program(&spl_token::ID)?;
associated_token_program.is_program(&spl_associated_token_account::ID)?;
metadata_program.is_program(&mpl_token_metadata::ID)?;
rent_sysvar.is_sysvar(&sysvar::rent::ID)?;
// Collect mint fee from payer using System Program transfer (idiomatic Solana way)
// This is safer and more consistent with reference programs (escrow-copy, universalsettle-copy)
let mint_fee = config.mint_fee_lamports;
if mint_fee > 0 {
invoke(
&solana_program::system_instruction::transfer(
payer_info.key,
fee_account_info.key,
mint_fee,
),
&[
payer_info.clone(),
fee_account_info.clone(),
system_program.clone(),
],
)?;
}
// Update config total fees collected
// Collect fees BEFORE operation (prevents fee payment without service)
let config_mut = config_info
.is_config()?
.is_writable()?
.as_account_mut::<Config>(&airdrop_api::ID)?;
config_mut.total_fees_collected = config_mut
.total_fees_collected
.checked_add(mint_fee)
.ok_or(ProgramError::ArithmeticOverflow)?;
// Create mint account (as PDA)
// Use Mint::LEN from spl_token instead of hardcoding 82
allocate_account_with_bump(
mint_info,
system_program,
payer_info,
Mint::LEN,
&spl_token::ID,
&[MINT, &mint_id, &noise],
mint_bump,
)?;
// Initialize mint with mint authority PDA
// Note: seeds and bump are for the MINT PDA (not mint_authority PDA)
// The mint account needs to sign because it's a PDA being initialized
initialize_mint_signed_with_bump(
mint_info,
mint_authority_info,
None, // Freeze authority = None (matches miracle-copy)
token_program,
rent_sysvar,
decimals,
&[MINT, &mint_id, &noise], // Mint PDA seeds (not mint_authority seeds!)
mint_bump, // Mint PDA bump (not mint_authority bump!)
)?;
// Create metadata account using Metaplex Token Metadata (same as miracle-copy)
let name = String::from_utf8_lossy(&args.name)
.trim_end_matches('\0')
.to_string();
let symbol = String::from_utf8_lossy(&args.symbol)
.trim_end_matches('\0')
.to_string();
let uri = String::from_utf8_lossy(&args.uri)
.trim_end_matches('\0')
.to_string();
// Check if metadata already exists (for shared mints)
if mint_metadata_info.data_is_empty() {
CreateMetadataAccountV3Cpi {
__program: metadata_program,
metadata: mint_metadata_info,
mint: mint_info,
mint_authority: mint_authority_info,
payer: payer_info,
update_authority: (mint_authority_info, true), // Mint authority can update metadata
system_program,
rent: Some(rent_sysvar),
__args: mpl_token_metadata::instructions::CreateMetadataAccountV3InstructionArgs {
data: mpl_token_metadata::types::DataV2 {
name,
symbol,
uri,
seller_fee_basis_points: 0,
creators: None,
collection: None,
uses: None,
},
is_mutable: true,
collection_details: None,
},
}
.invoke_signed(&[&[
MINT_AUTHORITY,
mint_pubkey.as_ref(),
&[mint_authority_bump],
]])?;
}
// If metadata exists, skip creation (mint is being reused)
// Verify mint treasury PDA
let (expected_mint_treasury, _mint_treasury_bump) = mint_treasury_pda(&mint_pubkey);
mint_treasury_info.has_address(&expected_mint_treasury)?;
// Create mint treasury token account (associated token account)
// This is the mint-specific treasury that holds all minted tokens
create_associated_token_account(
payer_info,
mint_treasury_info,
mint_treasury_tokens_info,
mint_info,
system_program,
token_program,
associated_token_program,
)?;
// Mint all tokens upfront to mint treasury
// Mint max_supply tokens directly (max_supply must be > 0)
// Mint tokens to mint treasury
// Note: mint_to_signed() derives bump automatically from seeds, so don't include bump in seeds array
mint_to_signed(
mint_info,
mint_treasury_tokens_info,
mint_authority_info,
token_program,
max_supply,
&[MINT_AUTHORITY, mint_pubkey.as_ref()], // Seeds only - bump is derived automatically
)?;
// Revoke mint authority after minting (immutable cap, standard SPL way)
// This prevents any further minting, enforcing max_supply at the SPL Token level
invoke_signed(
&set_authority(
token_program.key,
mint_info.key,
None, // Set to None = REVOKE mint authority
AuthorityType::MintTokens,
mint_authority_info.key,
&[mint_authority_info.key],
)?,
&[
token_program.clone(),
mint_info.clone(),
mint_authority_info.clone(),
],
&[&[MINT_AUTHORITY, mint_pubkey.as_ref(), &[mint_authority_bump]]],
)?;
// Emit event
let clock = Clock::get()?;
airdrop_api::event::MintCreatedEvent {
mint: mint_pubkey,
mint_authority: *mint_authority_info.key,
admin: *payer_info.key, // Payer is the one who created the mint
max_supply,
created_at: clock.unix_timestamp,
}
.log();
Ok(())
}