hopper-metaplex 0.2.0

Hopper-owned Metaplex Token Metadata program builders, PDA helpers, and account read helpers. Powers the metadata::* / master_edition::* field keywords on #[derive(Accounts)] and #[hopper::context].
Documentation
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
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
//! Builder structs for the three Metaplex Token Metadata calls Hopper
//! programs need most often.
//!
//! Each builder follows the same shape as `hopper_runtime::system::*`
//! and `hopper_runtime::token::*`: a struct with `&AccountView` and
//! `&str`/`u64`/`bool` fields, an `invoke()` method for unsigned CPI,
//! and an `invoke_signed()` method that takes `&[Signer]` for PDA
//! signing.
//!
//! ## Instruction discriminators
//!
//! These are the **enum-position** Borsh discriminators from
//! `mpl-token-metadata`'s `MetadataInstruction` enum, the canonical
//! on-chain encoding the program has accepted since deployment.
//! `mpl-token-metadata` 5.x added Shank-generated 8-byte discriminator
//! aliases for client tooling, but the on-chain dispatcher still
//! routes the legacy single-byte form. We use the legacy form because
//! it matches what's actually on chain and saves 7 bytes of
//! instruction data per call.
//!
//! - `CreateMetadataAccountV3` = 33
//! - `CreateMasterEditionV3`   = 17
//! - `UpdateMetadataAccountV2` = 15

use crate::constants::{MAX_NAME_LEN, MAX_SYMBOL_LEN, MAX_URI_LEN, MPL_TOKEN_METADATA_PROGRAM_ID};
use crate::encoding::BorshTape;
use hopper_runtime::account::AccountView;
use hopper_runtime::address::Address;
use hopper_runtime::error::ProgramError;
use hopper_runtime::instruction::{InstructionAccount, InstructionView, Signer};
use hopper_runtime::ProgramResult;

const DISC_UPDATE_METADATA_ACCOUNT_V2: u8 = 15;
const DISC_CREATE_MASTER_EDITION_V3: u8 = 17;
const DISC_CREATE_METADATA_ACCOUNT_V3: u8 = 33;

/// Borsh-shaped representation of Metaplex's `DataV2` payload.
///
/// Lower-level than the `CreateMetadataAccountV3` builder's flat
/// `name`/`symbol`/`uri`/`sfbp` fields. Use this directly when you
/// need to set creators / collection / uses; reach for the flat
/// builder fields when you don't.
#[derive(Clone, Copy)]
pub struct DataV2<'a> {
    pub name: &'a str,
    pub symbol: &'a str,
    pub uri: &'a str,
    pub seller_fee_basis_points: u16,
    /// Borsh `Option<Vec<Creator>>`. The current builder only emits
    /// `None` (no creators); pass `false` to keep the field empty.
    pub has_creators: bool,
    /// Borsh `Option<Collection>`. Builder emits `None`.
    pub has_collection: bool,
    /// Borsh `Option<Uses>`. Builder emits `None`.
    pub has_uses: bool,
}

impl<'a> DataV2<'a> {
    /// Construct a "simple" `DataV2` with no creators, collection, or
    /// uses. Most 1-of-1 NFT mints use exactly this shape.
    #[inline]
    pub const fn simple(
        name: &'a str,
        symbol: &'a str,
        uri: &'a str,
        seller_fee_basis_points: u16,
    ) -> Self {
        Self {
            name,
            symbol,
            uri,
            seller_fee_basis_points,
            has_creators: false,
            has_collection: false,
            has_uses: false,
        }
    }

    fn validate_lengths(&self) -> ProgramResult {
        if self.name.len() > MAX_NAME_LEN
            || self.symbol.len() > MAX_SYMBOL_LEN
            || self.uri.len() > MAX_URI_LEN
        {
            return Err(ProgramError::InvalidInstructionData);
        }
        Ok(())
    }

    /// Validate the Metaplex string length limits without building or
    /// invoking a CPI.
    ///
    /// `#[hopper::context]` uses this in generated validators for
    /// `metadata::{name,symbol,uri,seller_fee_basis_points}` so the
    /// caller gets a Hopper-side `InvalidInstructionData` error before
    /// the instruction attempts a Metaplex CPI.
    #[inline]
    pub fn validate_for_context(&self) -> ProgramResult {
        self.validate_lengths()
    }

    fn write_borsh(&self, tape: &mut BorshTape<'_>) -> ProgramResult {
        // Borsh layout of DataV2:
        //   string name | string symbol | string uri |
        //   u16 sfbp |
        //   Option<Vec<Creator>> | Option<Collection> | Option<Uses>
        tape.write_str(self.name)?;
        tape.write_str(self.symbol)?;
        tape.write_str(self.uri)?;
        tape.write_u16_le(self.seller_fee_basis_points)?;
        // Only the `simple` shape is supported in this first cut. The
        // flags are kept on the struct so a follow-up can extend the
        // builder without breaking source compatibility.
        if self.has_creators || self.has_collection || self.has_uses {
            return Err(ProgramError::InvalidInstructionData);
        }
        tape.write_option_none()?; // creators
        tape.write_option_none()?; // collection
        tape.write_option_none()?; // uses
        Ok(())
    }
}

/// Convert context-level `master_edition::max_supply = ...` values
/// into the `Option<u64>` expected by `CreateMasterEditionV3`.
///
/// Accepting both `u64` and `Option<u64>` keeps the field keyword
/// ergonomic for the common 1-of-1 case (`0`) while preserving the
/// Metaplex-native unlimited-prints representation (`None`).
pub trait IntoMasterEditionMaxSupply {
    /// Convert to Metaplex's `Option<u64>` max-supply wire shape.
    fn into_master_edition_max_supply(self) -> Option<u64>;
}

impl IntoMasterEditionMaxSupply for u64 {
    #[inline]
    fn into_master_edition_max_supply(self) -> Option<u64> {
        Some(self)
    }
}

impl IntoMasterEditionMaxSupply for Option<u64> {
    #[inline]
    fn into_master_edition_max_supply(self) -> Option<u64> {
        self
    }
}

// ── CreateMetadataAccountV3 ──────────────────────────────────────────

/// Builder for the Metaplex Token Metadata `CreateMetadataAccountV3`
/// instruction.
///
/// Initialises the metadata PDA for `mint` with the supplied
/// `DataV2`. The metadata PDA is at the canonical seeds
/// `["metadata", mpl_token_metadata_program_id, mint]` (see
/// [`crate::seeds::metadata_pda`]).
///
/// # Account ordering
///
/// 1. metadata          - writable, the PDA being created
/// 2. mint              - read-only, the SPL mint the metadata describes
/// 3. mint_authority    - signer (mint authority of `mint`)
/// 4. payer             - signer + writable (funds the new account)
/// 5. update_authority  - signer (the authority allowed to mutate the metadata later)
/// 6. system_program    - read-only
/// 7. rent (optional)   - read-only; modern Metaplex doesn't require it but
///    accepts it for backward compatibility
pub struct CreateMetadataAccountV3<'a> {
    pub metadata: &'a AccountView,
    pub mint: &'a AccountView,
    pub mint_authority: &'a AccountView,
    pub payer: &'a AccountView,
    pub update_authority: &'a AccountView,
    pub system_program: &'a AccountView,
    /// Rent sysvar account. Optional in current Metaplex; pass `None`
    /// to omit it from the account list (the on-chain program will
    /// load it from the sysvar cache when not supplied).
    pub rent: Option<&'a AccountView>,

    pub data: DataV2<'a>,
    pub is_mutable: bool,
}

impl CreateMetadataAccountV3<'_> {
    #[inline]
    pub fn invoke(&self) -> ProgramResult {
        self.invoke_signed(&[])
    }

    pub fn invoke_signed(&self, signers: &[Signer]) -> ProgramResult {
        self.data.validate_lengths()?;

        // Borsh-encoded data payload, capped at 320 bytes (covers the
        // worst case: 32 + 10 + 200 + length prefixes + flags = 263
        // bytes; rounded up for safety). Stack-allocated.
        let mut buf = [0u8; 320];
        let mut tape = BorshTape::new(&mut buf);
        tape.write_disc(DISC_CREATE_METADATA_ACCOUNT_V3)?;
        // CreateMetadataAccountArgsV3 layout:
        //   DataV2 data | bool is_mutable | Option<CollectionDetails> collection_details
        self.data.write_borsh_into(&mut tape)?;
        tape.write_bool(self.is_mutable)?;
        tape.write_option_none()?; // collection_details: None
        let len = tape.len();

        // Account list. The optional rent account is appended only if
        // present so the on-chain program sees the right account count.
        // Two branches because the metas array has a different length
        // in each case and Rust arrays carry their length in the type.
        if let Some(rent) = self.rent {
            let metas = [
                InstructionAccount::writable(self.metadata.address()),
                InstructionAccount::readonly(self.mint.address()),
                InstructionAccount::readonly_signer(self.mint_authority.address()),
                InstructionAccount::writable_signer(self.payer.address()),
                InstructionAccount::readonly_signer(self.update_authority.address()),
                InstructionAccount::readonly(self.system_program.address()),
                InstructionAccount::readonly(rent.address()),
            ];
            let views = [
                self.metadata,
                self.mint,
                self.mint_authority,
                self.payer,
                self.update_authority,
                self.system_program,
                rent,
            ];
            let instruction = InstructionView {
                program_id: &MPL_TOKEN_METADATA_PROGRAM_ID,
                data: &buf[..len],
                accounts: &metas,
            };
            hopper_runtime::cpi::invoke_signed(&instruction, &views, signers)
        } else {
            let metas = [
                InstructionAccount::writable(self.metadata.address()),
                InstructionAccount::readonly(self.mint.address()),
                InstructionAccount::readonly_signer(self.mint_authority.address()),
                InstructionAccount::writable_signer(self.payer.address()),
                InstructionAccount::readonly_signer(self.update_authority.address()),
                InstructionAccount::readonly(self.system_program.address()),
            ];
            let views = [
                self.metadata,
                self.mint,
                self.mint_authority,
                self.payer,
                self.update_authority,
                self.system_program,
            ];
            let instruction = InstructionView {
                program_id: &MPL_TOKEN_METADATA_PROGRAM_ID,
                data: &buf[..len],
                accounts: &metas,
            };
            hopper_runtime::cpi::invoke_signed(&instruction, &views, signers)
        }
    }
}

// ── CreateMasterEditionV3 ────────────────────────────────────────────

/// Builder for the Metaplex Token Metadata `CreateMasterEditionV3`
/// instruction.
///
/// Marks `mint` as a master edition with optional `max_supply` for
/// print editions. Set `max_supply = Some(0)` for a 1-of-1 NFT (no
/// prints), `Some(N)` for a numbered edition, `None` for unlimited.
///
/// # Account ordering
///
/// 1. edition           - writable, the master-edition PDA
/// 2. mint              - writable, the SPL mint
/// 3. update_authority  - signer
/// 4. mint_authority    - signer
/// 5. payer             - signer + writable
/// 6. metadata          - read-only, the metadata PDA from
///    `CreateMetadataAccountV3`
/// 7. token_program     - read-only (SPL Token program)
/// 8. system_program    - read-only
/// 9. rent (optional)   - read-only
pub struct CreateMasterEditionV3<'a> {
    pub edition: &'a AccountView,
    pub mint: &'a AccountView,
    pub update_authority: &'a AccountView,
    pub mint_authority: &'a AccountView,
    pub payer: &'a AccountView,
    pub metadata: &'a AccountView,
    pub token_program: &'a AccountView,
    pub system_program: &'a AccountView,
    pub rent: Option<&'a AccountView>,

    /// `None` for unlimited prints, `Some(0)` for a 1-of-1 NFT,
    /// `Some(N)` for a numbered edition.
    pub max_supply: Option<u64>,
}

impl CreateMasterEditionV3<'_> {
    #[inline]
    pub fn invoke(&self) -> ProgramResult {
        self.invoke_signed(&[])
    }

    pub fn invoke_signed(&self, signers: &[Signer]) -> ProgramResult {
        let mut buf = [0u8; 16];
        let mut tape = BorshTape::new(&mut buf);
        tape.write_disc(DISC_CREATE_MASTER_EDITION_V3)?;
        // CreateMasterEditionArgs { max_supply: Option<u64> }
        tape.write_option_u64_le(self.max_supply)?;
        let len = tape.len();

        if let Some(rent) = self.rent {
            let metas = [
                InstructionAccount::writable(self.edition.address()),
                InstructionAccount::writable(self.mint.address()),
                InstructionAccount::readonly_signer(self.update_authority.address()),
                InstructionAccount::readonly_signer(self.mint_authority.address()),
                InstructionAccount::writable_signer(self.payer.address()),
                InstructionAccount::readonly(self.metadata.address()),
                InstructionAccount::readonly(self.token_program.address()),
                InstructionAccount::readonly(self.system_program.address()),
                InstructionAccount::readonly(rent.address()),
            ];
            let views = [
                self.edition,
                self.mint,
                self.update_authority,
                self.mint_authority,
                self.payer,
                self.metadata,
                self.token_program,
                self.system_program,
                rent,
            ];
            let instruction = InstructionView {
                program_id: &MPL_TOKEN_METADATA_PROGRAM_ID,
                data: &buf[..len],
                accounts: &metas,
            };
            hopper_runtime::cpi::invoke_signed(&instruction, &views, signers)
        } else {
            let metas = [
                InstructionAccount::writable(self.edition.address()),
                InstructionAccount::writable(self.mint.address()),
                InstructionAccount::readonly_signer(self.update_authority.address()),
                InstructionAccount::readonly_signer(self.mint_authority.address()),
                InstructionAccount::writable_signer(self.payer.address()),
                InstructionAccount::readonly(self.metadata.address()),
                InstructionAccount::readonly(self.token_program.address()),
                InstructionAccount::readonly(self.system_program.address()),
            ];
            let views = [
                self.edition,
                self.mint,
                self.update_authority,
                self.mint_authority,
                self.payer,
                self.metadata,
                self.token_program,
                self.system_program,
            ];
            let instruction = InstructionView {
                program_id: &MPL_TOKEN_METADATA_PROGRAM_ID,
                data: &buf[..len],
                accounts: &metas,
            };
            hopper_runtime::cpi::invoke_signed(&instruction, &views, signers)
        }
    }
}

// ── UpdateMetadataAccountV2 ──────────────────────────────────────────

/// Builder for the Metaplex Token Metadata `UpdateMetadataAccountV2`
/// instruction.
///
/// Mutates an existing metadata account. Each field is `Option`-shaped;
/// `None` means "leave unchanged". The builder enforces this at the
/// Rust level so the caller can't accidentally overwrite a field they
/// didn't intend to.
///
/// # Account ordering
///
/// 1. metadata          - writable
/// 2. update_authority  - signer
pub struct UpdateMetadataAccountV2<'a> {
    pub metadata: &'a AccountView,
    pub update_authority: &'a AccountView,

    /// Replace `data` field. `None` keeps existing.
    pub new_data: Option<DataV2<'a>>,
    /// Rotate the update authority. `None` keeps existing.
    pub new_update_authority: Option<&'a Address>,
    /// Mark the primary sale as happened. `None` keeps existing
    /// (also: once `true`, can't go back to `false`).
    pub new_primary_sale_happened: Option<bool>,
    /// Toggle metadata mutability. `None` keeps existing.
    pub new_is_mutable: Option<bool>,
}

impl UpdateMetadataAccountV2<'_> {
    #[inline]
    pub fn invoke(&self) -> ProgramResult {
        self.invoke_signed(&[])
    }

    pub fn invoke_signed(&self, signers: &[Signer]) -> ProgramResult {
        if let Some(data) = self.new_data {
            data.validate_lengths()?;
        }

        let mut buf = [0u8; 384];
        let mut tape = BorshTape::new(&mut buf);
        tape.write_disc(DISC_UPDATE_METADATA_ACCOUNT_V2)?;

        // Option<DataV2> data
        match self.new_data {
            None => tape.write_option_none()?,
            Some(data) => {
                tape.write_option_some_tag()?;
                data.write_borsh_into(&mut tape)?;
            }
        }
        // Option<Pubkey> update_authority
        match self.new_update_authority {
            None => tape.write_option_none()?,
            Some(addr) => {
                tape.write_option_some_tag()?;
                tape.reserve_and_write_bytes(addr.as_array())?;
            }
        }
        // Option<bool> primary_sale_happened
        match self.new_primary_sale_happened {
            None => tape.write_option_none()?,
            Some(v) => {
                tape.write_option_some_tag()?;
                tape.write_bool(v)?;
            }
        }
        // Option<bool> is_mutable
        match self.new_is_mutable {
            None => tape.write_option_none()?,
            Some(v) => {
                tape.write_option_some_tag()?;
                tape.write_bool(v)?;
            }
        }

        let len = tape.len();
        let metas = [
            InstructionAccount::writable(self.metadata.address()),
            InstructionAccount::readonly_signer(self.update_authority.address()),
        ];
        let views = [self.metadata, self.update_authority];
        let instruction = InstructionView {
            program_id: &MPL_TOKEN_METADATA_PROGRAM_ID,
            data: &buf[..len],
            accounts: &metas,
        };
        hopper_runtime::cpi::invoke_signed(&instruction, &views, signers)
    }
}

// ── DataV2 / BorshTape glue ──────────────────────────────────────────
//
// DataV2 lives in this file (not encoding.rs) to keep encoding.rs
// purely about the Borsh tape mechanics. The `write_borsh_into`
// shim is named `write_borsh_into` (deliberately different from
// BorshTape::write_*) so it doesn't collide with any future BorshTape
// helper named `write_data_v2`.

impl<'a> DataV2<'a> {
    fn write_borsh_into(&self, tape: &mut BorshTape<'_>) -> ProgramResult {
        self.write_borsh(tape)
    }
}

// ── BorshTape extension for raw byte writes ──────────────────────────
//
// `UpdateMetadataAccountV2` writes a raw 32-byte pubkey for the
// optional new update authority; the encoding module's `write_str`
// would prefix it with a length tag (wrong format). We extend
// BorshTape inline here with a `reserve_and_write_bytes` helper used
// only by this file.

trait BorshTapeBytes {
    fn reserve_and_write_bytes(&mut self, bytes: &[u8]) -> ProgramResult;
}

impl<'a> BorshTapeBytes for BorshTape<'a> {
    fn reserve_and_write_bytes(&mut self, bytes: &[u8]) -> ProgramResult {
        // Re-use `write_str`-style logic without the length prefix.
        // The trait shim is the cleanest way to add this without
        // touching the encoding module's public API. If a second
        // callsite turns up the helper graduates into encoding.rs.
        if self.remaining() < bytes.len() {
            return Err(ProgramError::InvalidInstructionData);
        }
        for &b in bytes {
            self.write_u8(b)?;
        }
        Ok(())
    }
}