Guides

Create a staking program for your collection using only Typescript

This developer guide demonstrates how to create a staking program for your collection using only TypeScript, leveraging the attribute plugin and freeze delegate. This approach eliminates the need for a smart contract to track staking time and manage staking/unstaking, making it more accessible for Web2 developer.

Introduction

Metaplex Core

Metaplex Core (“Core”) is a new standard created specifically for digital assets. By moving away from the standard SPL-Token Program, Metaplex eliminated the complexity and technical debt of the previous standard (Token Metadata), providing a clean and simple interface for digital assets.

This new implementation uses a single account design, significantly reducing minting costs and optimizing network load by streamlining instruction processes. Additionally, it features a flexible plugin system that allows developers to modify the behavior and functionality of assets.

Here are the biggest differences and improvements:

  • Unprecedented Cost Efficiency: Metaplex Core offers the lowest minting costs compared to available alternatives. For instance, an NFT that would cost 0.022 SOL with Token Metadata or 0.0046 SOL with Token Extensions can be minted with Core for just 0.0029 SOL.
  • Low Compute: Core operations have a small Compute Unit footprint. This allows more transactions to be included in one block. Instead of 205,000 CU for minting, Core requires just 17,000 CU.
  • Single Account Design: Unlike relying on a fungible Token Standard like SPL Token or Token Extensions (aka Token22), Core focuses on the needs of an NFT standard. This allows Core to use a single account that also tracks the owner.

If you want to learn more, click here

Starting off: Understanding the Logic behind the program

To create an NFT collection that can only be traded during US market hours, we need a reliable way of updating on-chain data based on the time of day. This is how the protocol design will look like:

Overview

This program operates with a standard TypeScript backend and uses the asset keypair authority in the secret to sign attribute changes.

To implement this example, you will need the following components:

  • An Asset
  • A Collection (optional, but relevant for this example)
  • The FreezeDelegate Plugin
  • The Attribute Plugin

The Freeze Delegate Plugin

The Freeze Delegate Plugin is an owner managed plugin, that means that it requires the owner's signature to be applied to the asset.

This plugin allows the delegate to freeze and unfreeze the asset, preventing transfers. The asset owner or plugin authority can revoke this plugin at any time, except when the asset is frozen (in which case it must be unfrozen before revocation).

Using this plugin is lightweight, as freezing/unfreezing the asset involves just changing a boolean value in the plugin data (the only argument being Frozen: bool).

Learn more about it here

The Attribute Plugin

The Attribute Plugin is an authority managed plugin, that means that it requires the authority's signature to be applied to the asset. For an asset included in a collection, the collection authority serves as the authority since the asset's authority field is occupied by the collection address.

This plugin allows for data storage directly on the assets, functioning as on-chain attributes or traits. These traits can be accessed directly by on-chain programs since they aren’t stored off-chain as it was for the mpl-program.

This plugin accepts an AttributeList field, which consists of an array of key and value pairs, both of which are strings.

Learn more about it here

The program Logic

For simplicity, this example includes only two instructions: the stake and unstake functions since are essential for a staking program to work as intended. While additional instructions, such as a spendPoint instruction, could be added to utilize accumulated points, this is left to the reader to implement.

Both the Stake and Unstake functions utilize, differently, the plugins introduced previously.

Before diving into the instructions, let’s spend some time talking about the attributes used, the staked and staked_time keys. The staked key indicates if the asset is staked and when it was staked if it was (unstaked = 0, staked = time of staked). The staked_time key tracks the total staking duration of the asset, updated only after an asset get’s unstaked.

Instructions:

  • Stake: This instruction applies the Freeze Delegate Plugin to freeze the asset by setting the flag to true. Additionally, it updates thestaked key in the Attribute Plugin from 0 to the current time.
  • Unstake: This instruction changes the flag of the Freeze Delegate Plugin and revokes it to prevent malicious entities from controlling the asset and demanding ransom to unfreeze it. It also updates the staked key to 0 and sets the staked_time to the current time minus the staked timestamp.

Building the Program: A Step-by-Step Guide

Now that we understand the logic behind our program, it’s time to dive into the code and bring everything together!

Umi and Core SDK Overview

In this guide, we’ll use both Umi and the Core SDK to create all necessary instructions.

Umi is a modular framework for building and using JavaScript clients for Solana programs. It provides a zero-dependency library that defines a set of core interfaces, enabling libraries to operate independently of specific implementations.

For more information, you can find an overview here

The basic Umi setup for this example will look like this:

import { generateSigner, createSignerFromKeypair, signerIdentity } from '@metaplex-foundation/umi'
import { createUmi } from '@metaplex-foundation/umi-bundle-defaults'
import wallet from "../wallet.json";

const umi = createUmi("https://api.devnet.solana.com", "finalized")

let keypair = umi.eddsa.createKeypairFromSecretKey(new Uint8Array(wallet));
const myKeypairSigner = createSignerFromKeypair(umi, keypair);
umi.use(signerIdentity(myKeypairSigner));

This setup involves:

  • Establishing a connection with Devnet for our Umi provider
  • Setting up a keypair to be used as both the authority and payer (umi.use(signerIdentity(...)))

Note: If you prefer to use a new keypair for this example, you can always use the generateSigner() function to create one.

Creating an Asset and Adding it to a Collection

Before diving into the logic for staking and unstaking, we should learn how to create an asset from scratch and add it to a collection.

Creating a Collection:

(async () => {
   // Generate the Collection KeyPair
   const collection = generateSigner(umi)
   console.log("\nCollection Address: ", collection.publicKey.toString())

   // Generate the collection
   const tx = await createCollection(umi, {
       collection: collection,
       name: 'My Collection',
       uri: 'https://example.com/my-collection.json',
   }).sendAndConfirm(umi)

   // Deserialize the Signature from the Transaction
   const signature = base58.deserialize(tx.signature)[0];
   console.log(`\nCollection Created: https://solana.fm/tx/${signature}?cluster=devnet-alpha`);
})();

Creating an Asset and Adding it to the Collection:

(async () => {
   // Generate the Asset KeyPair
   const asset = generateSigner(umi)
   console.log("\nAsset Address: ", asset.publicKey.toString())


   // Pass and Fetch the Collection
   const collection = publicKey("<collection_pubkey>")
   const fetchedCollection = await fetchCollection(umi, collection);


   // Generate the Asset
   const tx = await create(umi, {
       name: 'My NFT',
       uri: 'https://example.com/my-nft.json',
       asset,
       collection: fetchedCollection,
   }).sendAndConfirm(umi)


   // Deserialize the Signature from the Transaction
   const signature = base58.deserialize(tx.signature)[0];
   console.log(`Asset added to the Collection: https://solana.fm/tx/${signature}?cluster=devnet-alpha`);
})();

The Staking Instruction

Here's the full Staking instruction We begin by using the fetchAsset(...) instruction from the mpl-core SDK to retrieve information about an asset, including whether it has the attribute plugin and, if so, the attributes it contains.

const fetchedAsset = await fetchAsset(umi, asset);
  1. Check for the Attribute Plugin If the asset does not have the attribute plugin, add it and populate it with the staked and stakedTime keys.
if (!fetchedAsset.attributes) {
    tx = await addPlugin(umi, {
        asset,
        collection,
        plugin: {
        type: "Attributes",
        attributeList: [
            { key: "staked", value: currentTime },
            { key: "stakedTime", value: "0" },
        ],
        },
    }).sendAndConfirm(umi);
} else {
  1. Check for Staking Attributes: If the asset has the attribute plugin, ensure it contains the staking attributes necessary for the staking instruction.
} else {
    const assetAttribute = fetchedAsset.attributes.attributeList;
    const isInitialized = assetAttribute.some(
        (attribute) => attribute.key === "staked" || attribute.key === "stakedTime"
    );
  1. Check Staking Attributes Value If the asset has the staking attribute, check if the asset is already staked and if not we need to update the staked key with the current timeStamp as string:
if (isInitialized) {
    const stakedAttribute = assetAttribute.find(
        (attr) => attr.key === "staked"
    );


    if (stakedAttribute && stakedAttribute.value !== "0") {
        throw new Error("Asset is already staked");
    } else {
        assetAttribute.forEach((attr) => {
            if (attr.key === "staked") {
                attr.value = currentTime;
            }
        });
    }
} else {
  1. Add Staking Attributes if Not Present: If the asset does not have the staking attributes, add them to the existing attribute list.
} else {
    assetAttribute.push({ key: "staked", value: currentTime });
    assetAttribute.push({ key: "stakedTime", value: "0" });
}
  1. Update the Attribute Plugin: Finally, update the attribute plugin with the new or modified attributes.
tx = await updatePlugin(umi, {
    asset,
    collection,
    plugin: {
    type: "Attributes",
        attributeList: assetAttribute,
    },
}).sendAndConfirm(umi);

Here's the full instruction:

(async () => {
    // Pass the Asset and Collection
    const asset = publicKey("6AWm5uyhmHQXygeJV7iVotjvs2gVZbDXaGUQ8YGVtnJo");
    const collection = publicKey("CYKbtF2Y56QwQLYHUmpAPeiMJTz1DbBZGvXGgbB6VdNQ")

    // Fetch the Asset Attributes
    const fetchedAsset = await fetchAsset(umi, asset);
    console.log("\nThis is the current state of your Asset Attribute Plugin: ", fetchedAsset.attributes);

    const currentTime = new Date().getTime().toString();

    let tx;

    // Check if the Asset has an Attribute Plugin attached to it, if not, add it
    if (!fetchedAsset.attributes) {
        tx = await addPlugin(umi, {
            asset,
            collection,
            plugin: {
            type: "Attributes",
            attributeList: [
                { key: "staked", value: currentTime },
                { key: "stakedTime", value: "0" },
            ],
            },
        }).sendAndConfirm(umi);
    } else {
        // If it is, fetch the Asset Attribute Plugin attributeList
        const assetAttribute = fetchedAsset.attributes.attributeList;
        // Check if the Asset is already been staked
        const isInitialized = assetAttribute.some(
            (attribute) => attribute.key === "staked" || attribute.key === "stakedTime"
        );

        // If it is, check if it is already staked and if not update the staked attribute
        if (isInitialized) {
            const stakedAttribute = assetAttribute.find(
                (attr) => attr.key === "staked"
            );

            if (stakedAttribute && stakedAttribute.value !== "0") {
                throw new Error("Asset is already staked");
            } else {
                assetAttribute.forEach((attr) => {
                    if (attr.key === "staked") {
                        attr.value = currentTime;
                    }
                });
            }
        } else {
            // If it is not, add the staked & stakedTime attribute
            assetAttribute.push({ key: "staked", value: currentTime });
            assetAttribute.push({ key: "stakedTime", value: "0" });
        }

        // Update the Asset Attribute Plugin
        tx = await updatePlugin(umi, {
            asset,
            collection,
            plugin: {
            type: "Attributes",
                attributeList: assetAttribute,
            },
        }).sendAndConfirm(umi);
    }

    // Deserialize the Signature from the Transaction
    const signature = base58.deserialize(tx.signature)[0];
    console.log(`\nAsset Staked: https://solana.fm/tx/${signature}?cluster=devnet-alpha`);
})();

The Unstaking Instruction

The unstaking instruction will be even easier simpler because, since the unstaking instruction can be called only after the staking instruction, many of the checks are inherently covered by the staking instruction itself.

We start by calling the fetchAsset(...) instruction to retrieve all information about the asset.

const fetchedAsset = await fetchAsset(umi, asset);
  1. Run all the checks for the attribute plugin

To verify if an asset has already gone through the staking instruction, the instruction check the attribute plugin for the following:

  • Does the attribute plugin exist on the asset?
  • Does it have the staked key?
  • Does it have the stakedTime key?

If any of these checks are missing, the asset has never gone through the staking instruction.

if (!fetchedAsset.attributes) {
    throw new Error(
        "Asset has no Attribute Plugin attached to it. Please go through the stake instruction before."
    );
}

const assetAttribute = fetchedAsset.attributes.attributeList;
const stakedTimeAttribute = assetAttribute.find((attr) => attr.key === "stakedTime");
if (!stakedTimeAttribute) {
    throw new Error(
        "Asset has no stakedTime attribute attached to it. Please go through the stake instruction before."
    );
}

const stakedAttribute = assetAttribute.find((attr) => attr.key === "staked");
if (!stakedAttribute) {
    throw new Error(
        "Asset has no staked attribute attached to it. Please go through the stake instruction before."
    );
}
  1. Check if the Asset is staked and update the attributes

Once we confirm that the asset has the staking attributes, we check if the asset is currently staked. If it is staked, we update the staking attributes as follows:

  • Set Staked field to zero
  • Update stakedTime to stakedTime + (currentTimestamp - stakedTimestamp)
if (stakedAttribute.value === "0") {
    throw new Error("Asset is not staked");
} else {
    const stakedTimeValue = parseInt(stakedTimeAttribute.value);
    const stakedValue = parseInt(stakedAttribute.value);
    const elapsedTime = new Date().getTime() - stakedValue;

    assetAttribute.forEach((attr) => {
        if (attr.key === "stakedTime") {
            attr.value = (stakedTimeValue + elapsedTime).toString();
        }
        if (attr.key === "staked") {
            attr.value = "0";
        }
    });
}
  1. Update the Attribute Plugin Finally, update the attribute plugin with the updated data from the unstaking instruction.
let tx = await updatePlugin(umi, {
    asset,
    collection,
    plugin: {
        type: "Attributes",
        attributeList: assetAttribute,
    },
}).sendAndConfirm(umi);

const signature = base58.deserialize(tx.signature)[0];
console.log(`\nAsset Unstaked: https://solana.fm/tx/${signature}?cluster=devnet-alpha`);

Here's the full instruction:

(async () => {
    // Pass the Asset and Collection
    const asset = publicKey("<asset-pubkey>");
    const collection = publicKey("<collection-pubkey>")

    // Fetch the Asset Attributes
    const fetchedAsset = await fetchAsset(umi, asset);
    console.log("This is the current state of your Asset Attribute Plugin", fetchedAsset.attributes);

    // If there is no attribute plugin attached to the asset, throw an error
    if (!fetchedAsset.attributes) {
      throw new Error(
        "Asset has no Attribute Plugin attached to it. Please go through the stake instruction before."
      );
    }
    
    const assetAttribute = fetchedAsset.attributes.attributeList;
    // Check if the asset has a stakedTime attribute attached to it, if not throw an error
    const stakedTimeAttribute = assetAttribute.find((attr) => attr.key === "stakedTime");
    if (!stakedTimeAttribute) {
      throw new Error(
        "Asset has no stakedTime attribute attached to it. Please go through the stake instruction before."
      );
    }

    // Check if the asset has a staked attribute attached to it, if not throw an error
    const stakedAttribute = assetAttribute.find((attr) => attr.key === "staked");
    if (!stakedAttribute) {
      throw new Error(
        "Asset has no staked attribute attached to it. Please go through the stake instruction before."
      );
    }

    // Check if the asset is already staked (!0), if not throw an error.
    if (stakedAttribute.value === "0") {
      throw new Error("Asset is not staked");
    } else {
      const stakedTimeValue = parseInt(stakedTimeAttribute.value);
      const stakedValue = parseInt(stakedAttribute.value);
      const elapsedTime = new Date().getTime() - stakedValue;

      // Update the stakedTime attribute to the new value and the staked attribute to 0
      assetAttribute.forEach((attr) => {
        if (attr.key === "stakedTime") {
          attr.value = (stakedTimeValue + elapsedTime).toString();
        }
        if (attr.key === "staked") {
          attr.value = "0";
        }
      });
    }

    // Update the Asset Attribute Plugin with the new attributeList
    let tx = await updatePlugin(umi, {
      asset,
      collection,
      plugin: {
        type: "Attributes",
        attributeList: assetAttribute,
      },
    }).sendAndConfirm(umi);

    const signature = base58.deserialize(tx.signature)[0];
    console.log(`\nAsset Unstaked: https://solana.fm/tx/${signature}?cluster=devnet-alpha`);
})();

Conclusion

Congratulations! You are now equipped to create a staking solution for your NFT collection! If you want to learn more about Core and Metaplex, check out the developer hub.

Previous
Oracle Plugin Example