Metaplex under the hood: How Plugins Enable Lightweight, Granular State Management

LE◎
Metaplex
11 min readOct 1, 2024

--

Today, we’re diving deep to explore how Metaplex Core Assets manage to offer fully granular permissions and endless customization options — all while staying remarkably lightweight.

We’re going to look at:

  • State Blobs and Single Account Struct
  • Anatomy of a Core Asset
  • Granular State Managment and Permissions
  • Plugins 101

Everything is a State Blobs

In a recent conversation with Keith (aka Blockiosaurus), a cool insight behind how Core was built came up: “Fundamentally, onchain data is just a “blob” — a collection of unstructured bytes that we typically assign a specific meaning or structure to.”

Traditionally, protocols take these blobs and lock them into fixed structures to make them easier to work with. But when you’re crafting a standard that can truly evolve with the future of digital assets, you can’t just play by the old rules — you have to challenge the status quo.

Hearing this bit of lore got me curious. I wanted to dive deeper into the design choices behind Core and understand how this concept of treating on-chain data as fluid “blobs” was leveraged to build a flexible, plugin-based system to serve a wide range of use cases.

Resolving Account is a Pain

The experience of building token metadata for NFTs on top of the SPL Token Program exposed a big limitation: trying to add custom behaviors by creating new accounts makes the standard bulky, complex, and hard to use. Every additional account becomes an obstacle, slowing down innovation and making the whole system less flexible.

Lesson learned: the new standard needed to be a single account — one that could hold both the data and any custom behaviours associated with the asset.

But how do you build a standard that takes the “crazy idea” of treating every piece of on-chain data as a fluid blob, and use that to create a single-account solution that handles thousands of different customizations and use cases?

Anatomy of a Core Account

Each Core State Accounts, like AssetV1 and CollectionV1, are composed of two main parts:

  • The Data: This is where all the essential asset information lives. This part is always there and it has a “fixed” length (except for those flexible fields like name and URI).
  • The Plugin Metadata: This is the optional part of the account where all the additional functionalities and custom behaviours are defined.

The Plugin Metadata

The Plugin Metadata is where the real magic happens. To help us understand where this metadata begins, there’s a handy helper function within the DataBlob trait, called get_size. This function calculates the size of the asset by adding the base length of the AssetV1 struct with the lengths of the asset's name and URI, plus any optional sequences.


// mpl-core/src/state/asset.rs

impl DataBlob for AssetV1 {
fn get_size(&self) -> usize {
let mut size = AssetV1::BASE_LENGTH + self.name.len() + self.uri.len();
if self.seq.is_some() {
size += size_of::<u64>();
}
size
}
}

This get_size function is then used inside the create_meta_idempotent function to check if the Plugin Metadata has already been created by comparing the size of the asset with the size of the DataBlob.

// mpl-core/src/plugins/utils.rs

pub fn create_meta_idempotent<'a, T: SolanaAccount + DataBlob>(
account: &AccountInfo<'a>,
payer: &AccountInfo<'a>,
system_program: &AccountInfo<'a>,
) -> Result<(T, PluginHeaderV1, PluginRegistryV1), ProgramError> {
let core = T::load(account, 0)?;
let header_offset = core.get_size();

// Check if the plugin header and registry exist.
if header_offset == account.data_len() {
// They don't exist, so create them.
/* .. */
} else {
// They exist, so load them.
/* .. */
}
}

The Plugin Header and Registry

At the start of the Plugin Metadata, there is thePluginHeader. This header contains the plugin_registry_offset—a pointer that indicates where in the account the Plugin Registry is located.

// programs/mpl-core/src/plugins/plugin_header.rs

pub struct PluginHeaderV1 {
/// The Discriminator of the header which doubles as a Plugin metadata version.
pub key: Key, // 1
/// The offset to the plugin registry stored at the end of the account.
pub plugin_registry_offset: usize, // 8
}

The Plugin Registry is where all the important action takes place. It holds a vector of both RegistryRecord and ExternalRegistryRecord, which store details about available plugins and their essential information.

// programs/mpl-core/src/plugins/plugin_registry.rs

pub struct PluginRegistryV1 {
/// The Discriminator of the header which doubles as a plugin metadata version.
pub key: Key, // 1
/// The registry of all plugins.
pub registry: Vec<RegistryRecord>, // 4
/// The registry of all adapter, third party, plugins.
pub external_registry: Vec<ExternalRegistryRecord>, // 4
}

/* .. */

pub struct RegistryRecord {
/// The type of plugin.
pub plugin_type: PluginType, // 2
/// The authority who has permission to utilize a plugin.
pub authority: Authority, // Variable
/// The offset to the plugin in the account.
pub offset: usize, // 8
}

/* .. */

pub struct ExternalRegistryRecord {
/// The adapter, third party plugin type.
pub plugin_type: ExternalPluginAdapterType,
/// The authority of the external plugin adapter.
pub authority: Authority,
/// The lifecyle events for which the the external plugin adapter is active.
pub lifecycle_checks: Option<Vec<(HookableLifecycleEvent, ExternalCheckResult)>>,
/// The offset to the plugin in the account.
pub offset: usize, // 8
/// For plugins with data, the offset to the data in the account.
pub data_offset: Option<usize>,
/// For plugins with data, the length of the data in the account.
pub data_len: Option<usize>,
}

Granular Permissions Enabled by State Blobs

Earlier in this article, we touched on the concept of State Blobs — treating on-chain data as unstructured “blobs” that can be defined dynamically. But we haven’t yet talked about how this is leveraged beyond just setting up the registry.

The flexibility to support customizable structures rather than forcing behaviors into rigid formats is what enables granular permissions and state managment for Core!

Each plugin defines its own “cookie policy” for permissions, specifying who can perform certain actions. While the authority is always present in the registry, everything else can be adjusted as needed.

The main challenge lies in ensuring these different authorities and permissions don’t conflict. Managing this complexity is part of Core’s validation system — a topic detailed enough to warrant its own discussion later.

Plugins101

Now that you have a clearer understanding of how plugins operates, you might be wondering how all this magic happens within the mpl-core program. Let's take a closer look, step-by-step, at the specific instructions for managing plugins within this system.

Note: you’re about to dive into a more technical landscape. But don’t worry! I’ll break down each step and the underlying logic to simplify the complexity as much as possible and some visual aids!

List all plugins in an account

Based on the previous discussion about the Plugin Registry, it shouldn’t come as a surprise the way the retrieval of the list of all the plugins associated with an asset works.

This process follows a “basic” sequence of events that we use whenever we’re dealing with plugin actions:

1. Get & Load the Plugin Header: Use the get_size() function we covered earlier to determine where the Plugin Header starts.

let header = PluginHeaderV1::load(account, asset.get_size())?;

2. Get & Load the Plugin Registry: Use the plugin_registry_offset field from the Plugin Header to locate the Plugin Registry.

let PluginRegistryV1 { registry, .. } =
PluginRegistryV1::load(account, header.plugin_registry_offset)?;

3. Iterate through the Plugin Registry: Loop through the RegistryRecord vector, collect each plugin present in the plugin_type, and return that as the response.

Ok(registry
.iter()
.map(|registry_record| registry_record.plugin_type)
.collect()
)

Full Instruction

// mpl-core/src/plugins/utils.rs

pub fn list_plugins(account: &AccountInfo) -> Result<Vec<PluginType>, ProgramError> {
let asset = AssetV1::load(account, 0)?;

if asset.get_size() == account.data_len() {
return Err(MplCoreError::PluginNotFound.into());
}

let header = PluginHeaderV1::load(account, asset.get_size())?;
let PluginRegistryV1 { registry, .. } =
PluginRegistryV1::load(account, header.plugin_registry_offset)?;

Ok(registry
.iter()
.map(|registry_record| registry_record.plugin_type)
.collect())
}

Fetch the plugin data from the registry

To fetch specific plugin data from the registry, we start from the point where we load the Plugin Registry:

1. Locate the Plugin in the Registry: Iterate through the Plugin Registry and find the plugin you’re looking for by matching the plugin_type. Retrieve the RegistryRecord.

let registry_record = registry
.iter()
.find(|record| record.plugin_type == plugin_type)
.ok_or(MplCoreError::PluginNotFound)?;

2. Deserialize the Plugin Data: Verify that the plugin at the offset saved in the RegistryRecord is the correct one and deserialize the plugin data by getting all the data from the flag onwards.

let plugin = Plugin::deserialize(&mut &(*account.data).borrow()[registry_record.offset..])?;

if PluginType::from(&plugin) != plugin_type {
return Err(MplCoreError::PluginNotFound.into());
}

let inner = U::deserialize(
&mut &(*account.data).borrow()[registry_record
.offset
.checked_add(1)
.ok_or(MplCoreError::NumericalOverflow)?..],
)?;

3. Return the Plugin Details: Return the authority, data, and offset from the deserialized slices.

Ok((registry_record.authority, inner, registry_record.offset))

Full Instruction

// mpl-core/src/plugins/utils.rs

pub fn fetch_plugin<T: DataBlob + SolanaAccount, U: BorshDeserialize>(
account: &AccountInfo,
plugin_type: PluginType,
) -> Result<(Authority, U, usize), ProgramError> {
let asset = T::load(account, 0)?;

if asset.get_size() == account.data_len() {
return Err(MplCoreError::PluginNotFound.into());
}

let header = PluginHeaderV1::load(account, asset.get_size())?;
let PluginRegistryV1 { registry, .. } =
PluginRegistryV1::load(account, header.plugin_registry_offset)?;


// Deserialize the plugin.
let plugin = Plugin::deserialize(&mut &(*account.data).borrow()[registry_record.offset..])?;

if PluginType::from(&plugin) != plugin_type {
return Err(MplCoreError::PluginNotFound.into());
}

let inner = U::deserialize(
&mut &(*account.data).borrow()[registry_record
.offset
.checked_add(1)
.ok_or(MplCoreError::NumericalOverflow)?..],
)?;

// Return the plugin and its authority.
Ok((registry_record.authority, inner, registry_record.offset))
}

Note: you might have seen that the fetch_plugin function uses a generic type U to handle the deserialization of the inner data of a plugin. This is because the inner data structure of plugins can vary widely in both type and size so this flexibility is crucial for handling varying internal representations.

Add Plugin

To add a specific plugin data from the registry, we start from the point where we load the Plugin Registry:

1. Check for Duplicate Plugins: Before adding the plugin, ensure that a plugin of the same type doesn’t already exist in the registry. If a duplicate is found, the function returns an error to avoid conflicting entries or overwrites.

if plugin_registry
.registry
.iter()
.any(|record| record.plugin_type == plugin_type)
{
return Err(MplCoreError::PluginAlreadyExists.into());
}

2. Calculate New Offsets and Size Adjustments: Determine where the new plugin data will be stored by calculating the new offset for the plugin registry. Also, compute the total size increase required for the account to accommodate the new plugin data. After that update the plugin_registry_offset in the header to reflect the new location for the registry.

let old_registry_offset = plugin_header.plugin_registry_offset;

let new_registry_record = RegistryRecord {
plugin_type,
offset: old_registry_offset,
authority: *authority,
};

let size_increase = plugin_size
.checked_add(new_registry_record.try_to_vec()?.len())
.ok_or(MplCoreError::NumericalOverflow)?;

let new_registry_offset = plugin_header
.plugin_registry_offset
.checked_add(plugin_size)
.ok_or(MplCoreError::NumericalOverflow)?;

plugin_header.plugin_registry_offset = new_registry_offset;
plugin_registry.registry.push(new_registry_record);

3. Resize or Reallocate Account Data: If needed, resize or reallocate the account to accommodate the new plugin data. This ensures there’s enough space for the updated data without causing overflow errors.

let new_size = account
.data_len()
.checked_add(size_increase)
.ok_or(MplCoreError::NumericalOverflow)?;

resize_or_reallocate_account(account, payer, system_program, new_size)?;

4. Save the Updated State: Save the updated plugin header, the serialized plugin data, and the updated plugin registry back to the account to finalize the changes.

plugin_header.save(account, header_offset)?;
plugin.save(account, old_registry_offset)?;
plugin_registry.save(account, new_registry_offset)?;

Full Instruction

// mpl-core/src/plugins/utils.rs

pub fn initialize_plugin<'a, T: DataBlob + SolanaAccount>(
plugin: &Plugin,
authority: &Authority,
plugin_header: &mut PluginHeaderV1,
plugin_registry: &mut PluginRegistryV1,
account: &AccountInfo<'a>,
payer: &AccountInfo<'a>,
system_program: &AccountInfo<'a>,
) -> ProgramResult {
let core = T::load(account, 0)?;
let header_offset = core.get_size();
let plugin_type = plugin.into();
let plugin_data = plugin.try_to_vec()?;
let plugin_size = plugin_data.len();

// You cannot add a duplicate plugin.
if plugin_registry
.registry
.iter()
.any(|record| record.plugin_type == plugin_type)
{
return Err(MplCoreError::PluginAlreadyExists.into());
}

let old_registry_offset = plugin_header.plugin_registry_offset;

let new_registry_record = RegistryRecord {
plugin_type,
offset: old_registry_offset,
authority: *authority,
};

let size_increase = plugin_size
.checked_add(new_registry_record.try_to_vec()?.len())
.ok_or(MplCoreError::NumericalOverflow)?;

let new_registry_offset = plugin_header
.plugin_registry_offset
.checked_add(plugin_size)
.ok_or(MplCoreError::NumericalOverflow)?;

plugin_header.plugin_registry_offset = new_registry_offset;

plugin_registry.registry.push(new_registry_record);

let new_size = account
.data_len()
.checked_add(size_increase)
.ok_or(MplCoreError::NumericalOverflow)?;

resize_or_reallocate_account(account, payer, system_program, new_size)?;
plugin_header.save(account, header_offset)?;
plugin.save(account, old_registry_offset)?;
plugin_registry.save(account, new_registry_offset)?;

Ok(())
}

Remove Plugin

To remove a specific plugin data from the registry, we start from the point where we load the Plugin Registry:

1. Locate the Plugin to Remove: Iterate through the plugin_registry to find the RegistryRecord corresponding to the plugin_type you want to delete. If the plugin is not found, return an error.

if let Some(index) = plugin_registry
.registry
.iter_mut()
.position(|record| record.plugin_type == *plugin_type)
{
let registry_record = plugin_registry.registry.remove(index);
let serialized_registry_record = registry_record.try_to_vec()?;

2. Retrieve and Remove the Plugin Data: Fetch the offset of the plugin to be removed and load the plugin data. Calculate the offsets and sizes to determine how much space will be freed up.

let plugin_offset = registry_record.offset;
let plugin = Plugin::load(account, plugin_offset)?;
let serialized_plugin = plugin.try_to_vec()?;

let next_plugin_offset = plugin_offset
.checked_add(serialized_plugin.len())
.ok_or(MplCoreError::NumericalOverflow)?;

3. Calculate the New Size of the Account: Calculate the new size of the account after removing the plugin data and the associated registry record.

let new_size = account
.data_len()
.checked_sub(serialized_registry_record.len())
.ok_or(MplCoreError::NumericalOverflow)?
.checked_sub(serialized_plugin.len())
.ok_or(MplCoreError::NumericalOverflow)?;

let new_registry_offset = header
.plugin_registry_offset
.checked_sub(serialized_plugin.len())
.ok_or(MplCoreError::NumericalOverflow)?;

4. Shift Remaining Data to Fill the Gap: Shift the remaining data in the account to fill the gap left by the removed plugin. This step prevents any unused or wasted space.

let data_to_move = header
.plugin_registry_offset
.checked_sub(next_plugin_offset)
.ok_or(MplCoreError::NumericalOverflow)?;

let src = account.data.borrow()[next_plugin_offset..].to_vec();

sol_memcpy(
&mut account.data.borrow_mut()[plugin_offset..],
&src,
data_to_move,
);

5. Update Offsets for Remaining Records and Resize Account: Adjust the offsets for the remaining plugins in both the internal and external registries to account for the removed data, and resize the account to reflect the freed-up space.

header.plugin_registry_offset = new_registry_offset;
header.save(account, asset.get_size())?;

// Move offsets for existing registry records.
plugin_registry.bump_offsets(plugin_offset, -(serialized_plugin.len() as isize))?;

plugin_registry.save(account, new_registry_offset)?;

resize_or_reallocate_account(account, payer, system_program, new_size)?;

Full Instruction

pub fn delete_plugin<'a, T: DataBlob>(
plugin_type: &PluginType,
asset: &T,
account: &AccountInfo<'a>,
payer: &AccountInfo<'a>,
system_program: &AccountInfo<'a>,
) -> ProgramResult {
if asset.get_size() == account.data_len() {
return Err(MplCoreError::PluginNotFound.into());
}

let mut header = PluginHeaderV1::load(account, asset.get_size())?;
let mut plugin_registry = PluginRegistryV1::load(account, header.plugin_registry_offset)?;

if let Some(index) = plugin_registry
.registry
.iter_mut()
.position(|record| record.plugin_type == *plugin_type)
{
let registry_record = plugin_registry.registry.remove(index);
let serialized_registry_record = registry_record.try_to_vec()?;

// Fetch the offset of the plugin to be removed.
let plugin_offset = registry_record.offset;
let plugin = Plugin::load(account, plugin_offset)?;
let serialized_plugin = plugin.try_to_vec()?;

// Get the offset of the plugin after the one being removed.
let next_plugin_offset = plugin_offset
.checked_add(serialized_plugin.len())
.ok_or(MplCoreError::NumericalOverflow)?;

// Calculate the new size of the account.
let new_size = account
.data_len()
.checked_sub(serialized_registry_record.len())
.ok_or(MplCoreError::NumericalOverflow)?
.checked_sub(serialized_plugin.len())
.ok_or(MplCoreError::NumericalOverflow)?;

let new_registry_offset = header
.plugin_registry_offset
.checked_sub(serialized_plugin.len())
.ok_or(MplCoreError::NumericalOverflow)?;

let data_to_move = header
.plugin_registry_offset
.checked_sub(next_plugin_offset)
.ok_or(MplCoreError::NumericalOverflow)?;

let src = account.data.borrow()[next_plugin_offset..].to_vec();
sol_memcpy(
&mut account.data.borrow_mut()[plugin_offset..],
&src,
data_to_move,
);

header.plugin_registry_offset = new_registry_offset;
header.save(account, asset.get_size())?;

// Move offsets for existing registry records.
plugin_registry.bump_offsets(plugin_offset, -(serialized_plugin.len() as isize))?;

plugin_registry.save(account, new_registry_offset)?;

resize_or_reallocate_account(account, payer, system_program, new_size)?;
} else {
return Err(MplCoreError::PluginNotFound.into());
}

Ok(())
}

Congratulation! You now know everything about what makes Core Plugins so special. This is just a part of the technology that power Core, the new Metaplex Standard that aims to revolutionize how we think about digital assets.

If want to learn more about Core, and Metaplex in general, check out the developer portal: here.

--

--