MPL CoreでSoulboundアセットを作成しよう!
Soulbound NFTsは特定のウォレットアドレスに永久的に紐づけられ、他の所有者に譲渡することができない非代替性トークンです。これらは、特定のアイデンティティに紐づけられるべき成果、資格、またはメンバーシップを表現するのに適しています。
概要
このガイドでは、MPL CoreとUmi Frameworkを使ってSoulboundアセットを作成する方法について解説します。TypeScriptでSoulbound NFTを実装したい開発者の方や、その仕組みを理解したい方に向けて、基本的な概念から実践的な実装方法までを網羅的に見ていきます。アセットをSoulboundにするためのさまざまなアプローチを紹介し、コレクション内で最初のSoulbound NFTを作成する手順を詳しく説明します。
MPL Coreでは、Soulbound NFTを作成するための主なアプローチが2つあります。
1. Permanent Freeze Delegate Plugin
アセットを完全に譲渡不可かつバーン不可にします。以下のいずれかのレベルで適用できます。
個々のアセットレベル
コレクションレベル(レンタルコストがより効率的)
コレクションレベルでの実装では、すべてのアセットを1回のトランザクションで解凍(thaw)することが可能です。
2. Oracle Plugin
アセットを譲渡不可にしますが、バーンは可能です。以下のいずれかのレベルで適用できます。
個々のアセットレベル
コレクションレベル(レンタルコストがより効率的)
コレクションレベルでの実装では、すべてのアセットを1回のトランザクションで解凍(thaw)することが可能です。
Permanent Freeze Delegate Pluginを使用したSoulbound NFTの作成
Permanent Freeze Delegate Pluginを使用すると、アセットを凍結して譲渡不可にする機能を提供します。Soulboundアセットを作成する際の手順は以下の通りです。
アセット作成時にPermanent Freeze Pluginを含める。
初期状態を「frozen(凍結)」に設定する。
Authorityを「None」に設定し、凍結状態を永久かつ変更不可にする。
これにより、譲渡も解凍もできない、完全にSoulboundなアセットを作成できます。以下のcode snippetでは、この3つのオプションをどこに追加すればよいかが示されています。
await create(umi, {
asset: assetSigner,
collection: collection,
name: "My Frozen Asset",
uri: "https://example.com/my-asset.json",
plugins: [
{
type: 'PermanentFreezeDelegate', // Include the Permanent Freeze plugin
frozen: true, // Set the initial state to frozen
authority: { type: "None" }, // Set the authority to None
},
],
})
アセットレベルでの実装
Permanent Freeze Delegate Pluginは、個々のアセットに適用することで、それらをSoulboundにすることができます。この方法はより細かい制御が可能ですが、より多くのレンタルコストがかかり、アセットごとに個別の解凍トランザクションが必要になります(万が一、Soulboundではなくする場合に)
import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";
import { mplCore } from "@metaplex-foundation/mpl-core";
import {
generateSigner,
keypairIdentity,
publicKey,
sol,
} from "@metaplex-foundation/umi";
import {
createCollection,
create,
fetchCollection,
transfer,
fetchAssetV1,
} from "@metaplex-foundation/mpl-core";
import { base58 } from "@metaplex-foundation/umi/serializers";
// Define a dummy destination wallet for testing transfer restrictions
const DESTINATION_WALLET = publicKey("CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d");
(async () => {
// Step 1: Initialize Umi with devnet RPC endpoint
const umi = createUmi(
"YOUR ENDPOINT"
).use(mplCore());
// Step 2: Create and fund a test wallet
const walletSigner = generateSigner(umi);
umi.use(keypairIdentity(walletSigner));
console.log("Funding test wallet with devnet SOL...");
await umi.rpc.airdrop(walletSigner.publicKey, sol(0.1));
// Step 3: Create a new collection to hold our frozen asset
console.log("Creating parent collection...");
const collectionSigner = generateSigner(umi);
await createCollection(umi, {
collection: collectionSigner,
name: "My Collection",
uri: "https://example.com/my-collection.json",
}).sendAndConfirm(umi);
// Wait for transaction confirmation
await new Promise(resolve => setTimeout(resolve, 15000));
// Fetch and verify the collection was created
const collection = await fetchCollection(umi, collectionSigner.publicKey);
console.log("Collection created successfully:", collectionSigner.publicKey);
// Step 4: Create a frozen asset within the collection
console.log("Creating frozen asset...");
const assetSigner = generateSigner(umi);
// Create the asset with permanent freeze using the PermanentFreezeDelegate plugin
await create(umi, {
asset: assetSigner,
collection: collection,
name: "My Frozen Asset",
uri: "https://example.com/my-asset.json",
plugins: [
{
// The PermanentFreezeDelegate plugin permanently freezes the asset
type: 'PermanentFreezeDelegate',
frozen: true, // Set the asset as frozen
authority: { type: "None" }, // No authority can unfreeze it
},
],
}).sendAndConfirm(umi);
// Wait for transaction confirmation
await new Promise(resolve => setTimeout(resolve, 15000));
// Fetch and verify the asset was created
const asset = await fetchAssetV1(umi, assetSigner.publicKey);
console.log("Frozen asset created successfully:", assetSigner.publicKey);
// Step 5: Demonstrate that the asset is truly frozen
console.log(
"Testing frozen property by attempting a transfer (this should fail)..."
);
// Attempt to transfer the asset (this will fail due to freeze)
const transferResponse = await transfer(umi, {
asset: asset,
newOwner: DESTINATION_WALLET,
collection,
}).sendAndConfirm(umi, { send: { skipPreflight: true } });
// Log the failed transfer attempt signature
console.log(
"Transfer attempt signature:",
base58.deserialize(transferResponse.signature)[0]
);
})();
コレクションレベルでの実装
すべてのアセットをSoulboundにする必要があるコレクションの場合、コレクションレベルでプラグインを適用する方が効率的です。この方法ではレンタルコストが抑えられ、コレクション全体を1回のトランザクションで解凍することが可能です。
import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";
import { mplCore } from "@metaplex-foundation/mpl-core";
import {
generateSigner,
keypairIdentity,
publicKey,
sol,
} from "@metaplex-foundation/umi";
import {
createCollection,
create,
fetchCollection,
transfer,
fetchAssetV1,
} from "@metaplex-foundation/mpl-core";
import { base58 } from "@metaplex-foundation/umi/serializers";
// Define a dummy destination wallet for testing transfer restrictions
const DESTINATION_WALLET = publicKey("CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d");
(async () => {
// Step 1: Initialize Umi with devnet RPC endpoint
const umi = createUmi(
"YOUR ENDPOINT"
).use(mplCore());
// Step 2: Create and fund a test wallet
const walletSigner = generateSigner(umi);
umi.use(keypairIdentity(walletSigner));
console.log("Funding test wallet with devnet SOL...");
await umi.rpc.airdrop(walletSigner.publicKey, sol(0.1));
// Wait for airdrop confirmation
await new Promise(resolve => setTimeout(resolve, 15000));
// Step 3: Create a new frozen collection
console.log("Creating frozen collection...");
const collectionSigner = generateSigner(umi);
await createCollection(umi, {
collection: collectionSigner,
name: "Frozen Collection",
uri: "https://example.com/my-collection.json",
plugins: [
{
// The PermanentFreezeDelegate plugin permanently freezes the collection
type: 'PermanentFreezeDelegate',
frozen: true, // Set the collection as frozen
authority: { type: "None" }, // No authority can unfreeze it
},
],
}).sendAndConfirm(umi);
// Wait for collection creation confirmation
await new Promise(resolve => setTimeout(resolve, 15000));
// Fetch and verify the collection was created
const collection = await fetchCollection(umi, collectionSigner.publicKey);
console.log("Frozen collection created successfully:", collectionSigner.publicKey);
// Step 4: Create an asset within the frozen collection
console.log("Creating asset in frozen collection...");
const assetSigner = generateSigner(umi);
await create(umi, {
asset: assetSigner,
collection: collection,
name: "Frozen Asset",
uri: "https://example.com/my-asset.json",
}).sendAndConfirm(umi);
// Wait for asset creation confirmation
await new Promise(resolve => setTimeout(resolve, 15000));
// Fetch and verify the asset was created
const asset = await fetchAssetV1(umi, assetSigner.publicKey);
console.log("Asset created successfully in frozen collection:", assetSigner.publicKey);
// Step 5: Demonstrate that the asset is frozen by the collection
console.log(
"Testing frozen property by attempting a transfer (this should fail)..."
);
// Attempt to transfer the asset (this will fail due to collection freeze)
const transferResponse = await transfer(umi, {
asset: asset,
newOwner: DESTINATION_WALLET,
collection,
}).sendAndConfirm(umi, { send: { skipPreflight: true } });
// Log the failed transfer attempt signature
console.log(
"Transfer attempt signature:",
base58.deserialize(transferResponse.signature)[0]
);
})();
Oracle Pluginを使用したSoulbound NFTの作成
Oracle Pluginは、アセットのさまざまなライフサイクルイベントを承認または拒否する方法を提供します。Soulbound NFTを作成する際には、Metaplexによってデプロイされた特別なOracleを使用することができます。このOracleは、譲渡イベントを常に拒否しながら、バーンなどの他の操作を許可します。Permanent Freeze Delegate Pluginとは異なり、譲渡はできないものの、アセットはバーン可能な状態に保たれます。
Oracle Pluginを使用してSoulboundアセットを作成する際は、このプラグインをアセットにアタッチします。これは、作成時またはその後に行うことができます。この例では、MetaplexがデプロイしたデフォルトのOracleを使用しています。このOracleは常に譲渡を拒否します。
これにより、譲渡不可ですがバーン可能な永久的なSoulboundアセットを作成できます。以下のcode snippetでは、その方法が示されています。
const ORACLE_ACCOUNT = publicKey(
"GxaWxaQVeaNeFHehFQEDeKR65MnT6Nup81AGwh2EEnuq"
);
await create(umi, {
asset: assetSigner,
collection: collection,
name: "My Soulbound Asset",
uri: "https://example.com/my-asset.json",
plugins: [
{
// The Oracle plugin allows us to control transfer permissions
type: "Oracle",
resultsOffset: {
type: "Anchor",
},
baseAddress: ORACLE_ACCOUNT,
lifecycleChecks: {
// Configure the Oracle to reject all transfer attempts
transfer: [CheckResult.CAN_REJECT],
},
baseAddressConfig: undefined,
},
],
})
アセットレベルでの実装
Oracle Pluginを使用することで、個々のアセットを譲渡不可にしつつ、バーンの能力を保持することができます。これにより、アセットを破棄する必要がある場合にも柔軟性を確保できます。
import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";
import { mplCore } from "@metaplex-foundation/mpl-core";
import {
generateSigner,
keypairIdentity,
publicKey,
sol,
} from "@metaplex-foundation/umi";
import {
createCollection,
create,
fetchCollection,
CheckResult,
transfer,
fetchAssetV1,
} from "@metaplex-foundation/mpl-core";
import { base58 } from "@metaplex-foundation/umi/serializers";
// Define the Oracle account that will control transfer permissions
// This is an Oracle deployed by Metaplex that always rejects tranferring
const ORACLE_ACCOUNT = publicKey(
"GxaWxaQVeaNeFHehFQEDeKR65MnT6Nup81AGwh2EEnuq"
);
// Define a dummy destination wallet for testing transfer restrictions
const DESTINATION_WALLET = publicKey("CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d");
(async () => {
// Step 1: Initialize Umi with devnet RPC endpoint
const umi = createUmi(
"YOUR ENDPOINT"
).use(mplCore());
// Step 2: Create and fund a test wallet
const walletSigner = generateSigner(umi);
umi.use(keypairIdentity(walletSigner));
console.log("Funding test wallet with devnet SOL...");
await umi.rpc.airdrop(walletSigner.publicKey, sol(0.1));
// Step 3: Create a new collection to hold our soulbound asset
console.log("Creating parent collection...");
const collectionSigner = generateSigner(umi);
await createCollection(umi, {
collection: collectionSigner,
name: "My Collection",
uri: "https://example.com/my-collection.json",
}).sendAndConfirm(umi);
// Wait for transaction confirmation
await new Promise(resolve => setTimeout(resolve, 15000));
// Fetch and verify the collection was created
const collection = await fetchCollection(umi, collectionSigner.publicKey);
console.log("Collection created successfully:", collectionSigner.publicKey);
// Step 4: Create a soulbound asset within the collection
console.log("Creating soulbound asset...");
const assetSigner = generateSigner(umi);
// Create the asset with transfer restrictions using an Oracle plugin
await create(umi, {
asset: assetSigner,
collection: collection,
name: "My Soulbound Asset",
uri: "https://example.com/my-asset.json",
plugins: [
{
// The Oracle plugin allows us to control transfer permissions
type: "Oracle",
resultsOffset: {
type: "Anchor",
},
baseAddress: ORACLE_ACCOUNT,
lifecycleChecks: {
// Configure the Oracle to reject all transfer attempts
transfer: [CheckResult.CAN_REJECT],
},
baseAddressConfig: undefined,
},
],
}).sendAndConfirm(umi);
// Wait for transaction confirmation
await new Promise(resolve => setTimeout(resolve, 15000));
// Fetch and verify the asset was created
const asset = await fetchAssetV1(umi, assetSigner.publicKey);
console.log("Soulbound asset created successfully:", assetSigner.publicKey);
// Step 5: Demonstrate that the asset is truly soulbound
console.log(
"Testing soulbound property by attempting a transfer (this should fail)..."
);
// Attempt to transfer the asset (this will fail due to Oracle restrictions)
const transferResponse = await transfer(umi, {
asset: asset,
newOwner: DESTINATION_WALLET,
collection,
}).sendAndConfirm(umi, { send: { skipPreflight: true } });
// Log the failed transfer attempt signature
console.log(
"Transfer attempt signature:",
base58.deserialize(transferResponse.signature)[0]
);
})();
コレクションレベルでの実装
Oracle Pluginをコレクションレベルで適用すると、コレクション内のすべてのアセットが譲渡不可になりますが、バーンは可能な状態に保たれます。この方法はレンタルコストがより効率的で、コレクション全体の権限を一括で管理することができます。
import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";
import { mplCore } from "@metaplex-foundation/mpl-core";
import {
generateSigner,
keypairIdentity,
publicKey,
sol,
} from "@metaplex-foundation/umi";
import {
createCollection,
create,
fetchCollection,
CheckResult,
transfer,
fetchAssetV1,
} from "@metaplex-foundation/mpl-core";
import { base58 } from "@metaplex-foundation/umi/serializers";
// Define the Oracle account that will control transfer permissions
// This is an Oracle deployed by Metaplex that always rejects transferring
const ORACLE_ACCOUNT = publicKey(
"GxaWxaQVeaNeFHehFQEDeKR65MnT6Nup81AGwh2EEnuq"
);
// Define a dummy destination wallet for testing transfer restrictions
const DESTINATION_WALLET = publicKey("CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d");
(async () => {
// Step 1: Initialize Umi with devnet RPC endpoint
const umi = createUmi(
"YOUR ENDPOINT"
).use(mplCore());
// Step 2: Create and fund a test wallet
const walletSigner = generateSigner(umi);
umi.use(keypairIdentity(walletSigner));
console.log("Funding test wallet with devnet SOL...");
await umi.rpc.airdrop(walletSigner.publicKey, sol(0.1));
// Wait for airdrop confirmation
await new Promise(resolve => setTimeout(resolve, 15000));
// Step 3: Create a new collection with transfer restrictions
console.log("Creating soulbound collection...");
const collectionSigner = generateSigner(umi);
await createCollection(umi, {
collection: collectionSigner,
name: "Soulbound Collection",
uri: "https://example.com/my-collection.json",
plugins: [
{
// The Oracle plugin allows us to control transfer permissions
type: "Oracle",
resultsOffset: {
type: "Anchor",
},
baseAddress: ORACLE_ACCOUNT,
lifecycleChecks: {
// Configure the Oracle to reject all transfer attempts
transfer: [CheckResult.CAN_REJECT],
},
baseAddressConfig: undefined,
},
],
}).sendAndConfirm(umi);
// Wait for collection creation confirmation
await new Promise(resolve => setTimeout(resolve, 15000));
// Fetch and verify the collection was created
const collection = await fetchCollection(umi, collectionSigner.publicKey);
console.log("Soulbound collection created successfully:", collectionSigner.publicKey);
// Step 4: Create a soulbound asset within the collection
console.log("Creating soulbound asset...");
const assetSigner = generateSigner(umi);
await create(umi, {
asset: assetSigner,
collection: collection,
name: "Soulbound Asset",
uri: "https://example.com/my-asset.json",
}).sendAndConfirm(umi);
// Wait for asset creation confirmation
await new Promise(resolve => setTimeout(resolve, 15000));
// Fetch and verify the asset was created
const asset = await fetchAssetV1(umi, assetSigner.publicKey);
console.log("Soulbound asset created successfully:", assetSigner.publicKey);
// Step 5: Demonstrate that the asset is truly soulbound
console.log(
"Testing soulbound property by attempting a transfer (this should fail)..."
);
// Attempt to transfer the asset (this will fail due to Oracle restrictions)
const transferResponse = await transfer(umi, {
asset: asset,
newOwner: DESTINATION_WALLET,
collection,
}).sendAndConfirm(umi, { send: { skipPreflight: true } });
// Log the failed transfer attempt signature
console.log(
"Transfer attempt signature:",
base58.deserialize(transferResponse.signature)[0]
);
})();