Appdata Pluginを活用して「Event Ticketing Platform」を作成してみよう!
この記事では、新しい「Appdata Plugin」を活用して、チケットをデジタル資産として生成し、発行者以外の信頼できるソース(例えば、会場管理者など)によって検証できるチケットソリューションを作成します。
はじめに
External Plugin
「External Plugin」は、その動作が外部のソースによって制御されるプラグインです。コアプログラムはこれらのプラグイン用のアダプターを提供しますが、開発者は外部のデータソースをこのアダプターに指定することで、その動作を決定します。
各「External Adapter」は「Lifecycle Events」に「Lifecycle checks」を割り当てる能力があり、実行される「Lifecycle Events」の動作に影響を与えます。つまり、create、transfer、update、burnなどの「Lifecycle Events」に以下のチェックを割り当てることができます。
Listen: 「Lifecycle Events」が発生したときにプラグインに警告する「web3」ウェブフック。これはデータの追跡やアクションの実行に特に役立ちます。
Reject: プラグインが「Lifecycle Events」を拒否できます。
Approve: プラグインが「Lifecycle Events」を承認できます。
External Pluginについてさらに詳しく知りたい場合は、こちらをお読みください。
Appdata Plugin
「AppData Plugin」は、アセット/コレクション権限者が「data_authority」(外部の信頼できるソース)によって書き込みや変更が可能な任意のデータを保存することを可能にします。この「data_authority」は、アセット/コレクション権限者が決定した誰にでも割り当てることができます。「AppData Plugin」を使用することで、コレクション/アセット権限者は、信頼できる第三者にアセットへのデータ追加タスクを委譲できます。
新しいAppdata Pluginについて詳しく知りたい場合は、こちらをお読みください。
全体概要:プログラム設計
ここでは、以下の4つの基本的な操作を含むチケットソリューションを開発します。
Managerのセットアップ:チケットの作成と発行を担当する権限者を確立します。
イベントの作成:「collection asset」としてイベントを生成します。
個別チケットの作成:イベントコレクションの一部として個別のチケットを生成します。
会場運営の操作:会場運営者のための操作(例:チケット使用時のスキャン)を管理します。
チケットスキャンを操作する外部ソースの重要性
「AppData plugin」と「Core standard」が導入されるまで、アセットの属性変更の管理はオフチェーンストレージの制約により制限されていました。また、アセットの特定の部分に対する権限を委譲することも不可能でした。
この進歩は、規制されたユースケース(チケットシステムなど)にとってゲームチェンジャーとなります。なぜなら、会場の管理者に属性変更やデータ面での制御権を与えることなく、アセットにデータを追加することを可能にするからです。
このセットアップにより、不正行為のリスクが軽減されます。チケットを発行する会社はアセットの変更不可能な記録を保ちつつ、チケットを使用済みとマークするような特定のデータ更新は「AppData plugin」を通じて安全に管理されます。
PDAsの代わりにDigital Assetsを使用してデータを保存する
イベント関連データに対して「Program Derived Addresses (PDAs)」に依存する代わりに、イベント自体をコレクションアセットとして作成できます。このアプローチにより、すべてのチケットを「イベント」コレクションに含めることができ、一般的なイベントデータに簡単にアクセスでき、イベントの詳細をチケットアセット自体と簡単にリンクさせることができます。同じ方法を個々のチケット関連データ(チケット番号、列、座席、価格など)に適用し、直接Assetに保存することができます。
デジタルアセットを扱う際に、外部のPDAsに依存するのではなく、「Collection」や「Asset」などのCoreアカウントを使用して関連データを保存することで、チケット購入者はデータをデシリアライズする必要なく、ウォレットから直接すべての関連イベント情報を閲覧できます。さらに、アセット自体に直接データを保存することで「Digital Asset Standard (DAS)」を活用して、以下のように単一の命令でウェブサイト上でデータを取得し表示することができます。
const ticketData = await fetchAsset(umi, ticket);
console.log("\nThis are all the ticket-related data: ", ticketData.attributes);
実践:プログラム
前提条件とセットアップ
Anchorを使用し、すべての必要なマクロが「lib.rs」ファイルにあるモノファイルアプローチを採用します。
declare_id:プログラムのオンチェーンアドレスを指定します。
#[program]:プログラムの命令ロジックを含むモジュールを指定します。
#[derive(Accounts)]:命令に必要なアカウントのリストを示すための構造に適用します。
#[account]:プログラム固有のカスタムアカウントタイプを作成するための構造に適用します。
スタイルの選択として、すべての命令のアカウント構造で「Signer」と「Payer」を分離します。多くの場合、同じアカウントが両方に使用されますが「Signer」がPDAの場合、アカウント作成の支払いができないため、2つの異なるフィールドが必要になります。この分離は私たちの指示には厳密には必要ありませんが、良い慣行とされています。
依存関係とインポート
この例では、主に「mpl_core」クレートを「anchor」の特徴を有効にして使用します。
mpl-core = { version = "x.x.x", features = ["anchor"] }
使用される依存関係は以下の通りです。
use anchor_lang::prelude::*;
use mpl_core::{
ID as MPL_CORE_ID,
fetch_external_plugin_adapter_data_info,
fetch_plugin,
instructions::{
CreateCollectionV2CpiBuilder,
CreateV2CpiBuilder,
WriteExternalPluginAdapterDataV1CpiBuilder,
UpdatePluginV1CpiBuilder
},
accounts::{BaseAssetV1, BaseCollectionV1},
types::{
AppDataInitInfo, Attribute, Attributes,
ExternalPluginAdapterInitInfo, ExternalPluginAdapterKey,
ExternalPluginAdapterSchema, PermanentBurnDelegate, UpdateAuthority,
PermanentFreezeDelegate, PermanentTransferDelegate, Plugin,
PluginAuthority, PluginAuthorityPair, PluginType
},
};
セットアップマネージャーの指示
セットアップマネージャーの指示は「manager」PDAを初期化し、マネージャーアカウント内にbumpsを保存するために必要な一回限りのプロセスです。
処理の大部分は「Account」構造内で行われます。
#[derive(Accounts)]
pub struct SetupManager<'info> {
pub signer: Signer<'info>,
#[account(mut)]
pub payer: Signer<'info>,
#[account(
init,
payer = payer,
space = Manager::INIT_SPACE,
seeds = [MANAGER_SEEDS.as_bytes()],
bump,
)]
pub manager: Account<'info, Manager>,
pub system_program: Program<'info, System>,
}
ここでは「init」マクロを使用して「Manager」アカウントを初期化します。この際、支払い者(payer)が家賃(rent)に十分なlamportsを転送し、「INIT_SPACE」変数が適切なバイト数を確保します。
#[account]
pub struct Manager {
pub bump: u8,
}
impl Space for Manager {
const INIT_SPACE: usize = 8 + 1;
}
指示自体では、signer seedsを使用する際の参照のために、bumpsを宣言して保存するだけです。これにより、マネージャーアカウントを使用するたびにbumpsを再計算する際の計算ユニットの無駄を避けることができます。
pub fn setup_manager(ctx: Context<SetupManager>) -> Result<()> {
ctx.accounts.manager.bump = ctx.bumps.manager;
Ok(())
}
イベント作成の指示
イベント作成の指示は、イベントをコレクション資産の形式でデジタル資産として設定します。これにより、関連するすべてのチケットとイベントデータをシームレスかつ体系的に含めることができます。
この指示のアカウント構造は、Setup Managerの指示とよく似ています。
#[derive(Accounts)]
pub struct CreateEvent<'info> {
pub signer: Signer<'info>,
#[account(mut)]
pub payer: Signer<'info>,
#[account(
seeds = [MANAGER_SEEDS.as_bytes()],
bump = manager.bump
)]
pub manager: Account<'info, Manager>,
#[account(mut)]
pub event: Signer<'info>,
pub system_program: Program<'info, System>,
#[account(address = MPL_CORE_ID)]
/// CHECK: This is checked by the address constraint
pub mpl_core_program: UncheckedAccount<'info>
}
主な違いは以下の通りです。
「Manager」アカウントはすでに初期化されており、イベントアカウントの更新権限として使用されます。
イベントアカウントは、可変でかつ署名者として設定され、この指示の間にCore Collection Accountに変換されます。
コレクションアカウント内に多くのデータを保存する必要があるため、関数に多数のパラメータが混在するのを避けるために、すべての入力を構造化された形式で渡します。
#[derive(AnchorDeserialize, AnchorSerialize)]
pub struct CreateEventArgs {
pub name: String,
pub uri: String,
pub city: String,
pub venue: String,
pub artist: String,
pub date: String,
pub time: String,
pub capacity: u64,
}
メイン関数の`create_event`は、上記の入力を利用してイベントコレクションを作成し、すべてのイベント詳細を含む属性を追加します。
pub fn create_event(ctx: Context<CreateEvent>, args: CreateEventArgs) -> Result<()> {
// Add an Attribute Plugin that will hold the event details
let mut collection_plugin: Vec<PluginAuthorityPair> = vec![];
let attribute_list: Vec<Attribute> = vec![
Attribute {
key: "City".to_string(),
value: args.city
},
Attribute {
key: "Venue".to_string(),
value: args.venue
},
Attribute {
key: "Artist".to_string(),
value: args.artist
},
Attribute {
key: "Date".to_string(),
value: args.date
},
Attribute {
key: "Time".to_string(),
value: args.time
},
Attribute {
key: "Capacity".to_string(),
value: args.capacity.to_string()
}
];
collection_plugin.push(
PluginAuthorityPair {
plugin: Plugin::Attributes(Attributes { attribute_list }),
authority: Some(PluginAuthority::UpdateAuthority)
}
);
// Create the Collection that will hold the tickets
CreateCollectionV2CpiBuilder::new(&ctx.accounts.mpl_core_program.to_account_info())
.collection(&ctx.accounts.event.to_account_info())
.update_authority(Some(&ctx.accounts.manager.to_account_info()))
.payer(&ctx.accounts.payer.to_account_info())
.system_program(&ctx.accounts.system_program.to_account_info())
.name(args.name)
.uri(args.uri)
.plugins(collection_plugin)
.invoke()?;
Ok(())
}
チケット作成の指示
チケット作成の指示は、イベントをコレクション資産の形式でデジタル資産として設定し、関連するすべてのチケットとイベントデータをシームレスかつ体系的に含めることができるようにします。
この指示全体は「create_event」とよく似ています。目的が非常に似ているためですが、今回はイベント資産を作成する代わりに「event collection」内に含まれるチケット資産を作成します。
#[derive(Accounts)]
pub struct CreateTicket<'info> {
pub signer: Signer<'info>,
#[account(mut)]
pub payer: Signer<'info>,
#[account(
seeds = [MANAGER_SEEDS.as_bytes()],
bump = manager.bump
)]
pub manager: Account<'info, Manager>,
#[account(
mut,
constraint = event.update_authority == manager.key(),
)]
pub event: Account<'info, BaseCollectionV1>,
#[account(mut)]
pub ticket: Signer<'info>,
pub system_program: Program<'info, System>,
#[account(address = MPL_CORE_ID)]
/// CHECK: This is checked by the address constraint
pub mpl_core_program: UncheckedAccount<'info>
}
アカウント構造の主な違いは以下の通りです。
イベントアカウントはすでに初期化されているため「BaseCollectionV1」アセットとして逆シリアル化でき「update_authority」がmanager PDAであることを確認できます。
チケットアカウントは、可変でかつ署名者として設定され、この指示の間にCore Collection Accountに変換されます。
この関数でも大量のデータを保存する必要があるため「create_event」指示ですでに行ったように、これらの入力を構造化された形式で渡します。
#[derive(AnchorDeserialize, AnchorSerialize)]
pub struct CreateTicketArgs {
pub name: String,
pub uri: String,
pub hall: String,
pub section: String,
pub row: String,
pub seat: String,
pub price: u64,
pub venue_authority: Pubkey,
}
指示について言及する際の主な違いは以下の通りです。
何か問題が発生した場合のセキュリティ層を追加するために「PermanentFreeze」「PermanentBurn」「PermanentTransfer」などの追加プラグインを組み込んでいます。
新しい「AppData」外部プラグインを使用して、指示の入力として渡す「venue_authority」によって管理されるバイナリデータを内部に保存します。
指示の冒頭で、発行されるチケットの総数が収容人数の上限を超えていないかを確認するサニティチェックを行います。
pub fn create_ticket(ctx: Context<CreateTicket>, args: CreateTicketArgs) -> Result<()> {
// Check that the maximum number of tickets has not been reached yet
let (_, collection_attribute_list, _) = fetch_plugin::<BaseCollectionV1, Attributes>(
&ctx.accounts.event.to_account_info(),
PluginType::Attributes
)?;
// Search for the Capacity attribute
let capacity_attribute = collection_attribute_list
.attribute_list
.iter()
.find(|attr| attr.key == "Capacity")
.ok_or(TicketError::MissingAttribute)?;
// Unwrap the Capacity attribute value
let capacity = capacity_attribute
.value
.parse::<u32>()
.map_err(|_| TicketError::NumericalOverflow)?;
require!(
ctx.accounts.event.num_minted < capacity,
TicketError::MaximumTicketsReached
);
// Add an Attribute Plugin that will hold the ticket details
let mut ticket_plugin: Vec<PluginAuthorityPair> = vec![];
let attribute_list: Vec<Attribute> = vec![
Attribute {
key: "Ticket Number".to_string(),
value: ctx.accounts.event.num_minted.checked_add(1).ok_or(TicketError::NumericalOverflow)?.to_string()
},
Attribute {
key: "Hall".to_string(),
value: args.hall
},
Attribute {
key: "Section".to_string(),
value: args.section
},
Attribute {
key: "Row".to_string(),
value: args.row
},
Attribute {
key: "Seat".to_string(),
value: args.seat
},
Attribute {
key: "Price".to_string(),
value: args.price.to_string()
}
];
ticket_plugin.push(
PluginAuthorityPair {
plugin: Plugin::Attributes(Attributes { attribute_list }),
authority: Some(PluginAuthority::UpdateAuthority)
}
);
ticket_plugin.push(
PluginAuthorityPair {
plugin: Plugin::PermanentFreezeDelegate(PermanentFreezeDelegate { frozen: false }),
authority: Some(PluginAuthority::UpdateAuthority)
}
);
ticket_plugin.push(
PluginAuthorityPair {
plugin: Plugin::PermanentBurnDelegate(PermanentBurnDelegate {}),
authority: Some(PluginAuthority::UpdateAuthority)
}
);
ticket_plugin.push(
PluginAuthorityPair {
plugin: Plugin::PermanentTransferDelegate(PermanentTransferDelegate {}),
authority: Some(PluginAuthority::UpdateAuthority)
}
);
let mut ticket_external_plugin: Vec<ExternalPluginAdapterInitInfo> = vec![];
ticket_external_plugin.push(ExternalPluginAdapterInitInfo::AppData(
AppDataInitInfo {
init_plugin_authority: Some(PluginAuthority::UpdateAuthority),
data_authority: PluginAuthority::Address{ address: args.venue_authority },
schema: Some(ExternalPluginAdapterSchema::Binary),
}
));
let signer_seeds = &[b"manager".as_ref(), &[ctx.accounts.manager.bump]];
// Create the Ticket
CreateV2CpiBuilder::new(&ctx.accounts.mpl_core_program.to_account_info())
.asset(&ctx.accounts.ticket.to_account_info())
.collection(Some(&ctx.accounts.event.to_account_info()))
.payer(&ctx.accounts.payer.to_account_info())
.authority(Some(&ctx.accounts.manager.to_account_info()))
.owner(Some(&ctx.accounts.signer.to_account_info()))
.system_program(&ctx.accounts.system_program.to_account_info())
.name(args.name)
.uri(args.uri)
.plugins(ticket_plugin)
.external_plugin_adapters(ticket_external_plugin)
.invoke_signed(&[signer_seeds])?;
Ok(())
}
チケットスキャンの指示
チケットスキャンの指示は、チケットがスキャンされた際にその状態を確認し更新することで、プロセスを完了させます。
#[derive(Accounts)]
pub struct ScanTicket<'info> {
pub owner: Signer<'info>,
pub signer: Signer<'info>,
#[account(mut)]
pub payer: Signer<'info>,
#[account(
seeds = [MANAGER_SEEDS.as_bytes()],
bump = manager.bump
)]
pub manager: Account<'info, Manager>,
#[account(
mut,
constraint = ticket.owner == owner.key(),
constraint = ticket.update_authority == UpdateAuthority::Collection(event.key()),
)]
pub ticket: Account<'info, BaseAssetV1>,
#[account(
mut,
constraint = event.update_authority == manager.key(),
)]
pub event: Account<'info, BaseCollectionV1>,
pub system_program: Program<'info, System>,
#[account(address = MPL_CORE_ID)]
/// CHECK: This is checked by the address constraint
pub mpl_core_program: UncheckedAccount<'info>,
}
アカウント構造の主な違いは以下の通りです。
チケットアカウントはすでに初期化されているため「BaseAssetV1」アセットとして逆シリアル化できます。これにより「update_authority」がイベントコレクションであること、および資産の所有者が「owner」アカウントであることを確認できます。
「owner」と「venue_authority」の両方を署名者として要求し、スキャンが両者によって認証され、エラーがないことを確認します。アプリケーションは「venue_authority」によって部分的に署名されたトランザクションを作成してブロードキャストし、チケットの「owner」がそれに署名して送信できるようにします。
指示では、まずAppdataプラグイン内にデータがあるかどうかをサニティチェックします。データがある場合、チケットはすでにスキャンされていることになります。
その後「Scanned」という文字列を含むu8のベクターで構成される「data」変数を作成し、後でAppdataプラグイン内に書き込みます。
最後に、デジタル資産をsoulboundにして、検証後に取引や譲渡ができないようにします。これにより、イベントのメモラビリアとしてのみ機能するようになります。
pub fn scan_ticket(ctx: Context<ScanTicket>) -> Result<()> {
let (_, app_data_length) = fetch_external_plugin_adapter_data_info::<BaseAssetV1>(
&ctx.accounts.ticket.to_account_info(),
None,
&ExternalPluginAdapterKey::AppData(
PluginAuthority::Address { address: ctx.accounts.signer.key() }
)
)?;
require!(app_data_length == 0, TicketError::AlreadyScanned);
let data: Vec<u8> = "Scanned".as_bytes().to_vec();
WriteExternalPluginAdapterDataV1CpiBuilder::new(&ctx.accounts.mpl_core_program.to_account_info())
.asset(&ctx.accounts.ticket.to_account_info())
.collection(Some(&ctx.accounts.event.to_account_info()))
.payer(&ctx.accounts.payer.to_account_info())
.system_program(&ctx.accounts.system_program.to_account_info())
.key(ExternalPluginAdapterKey::AppData(PluginAuthority::Address { address: ctx.accounts.signer.key() }))
.data(data)
.invoke()?;
let signer_seeds = &[b"manager".as_ref(), &[ctx.accounts.manager.bump]];
UpdatePluginV1CpiBuilder::new(&ctx.accounts.mpl_core_program.to_account_info())
.asset(&ctx.accounts.ticket.to_account_info())
.collection(Some(&ctx.accounts.event.to_account_info()))
.payer(&ctx.accounts.payer.to_account_info())
.authority(Some(&ctx.accounts.manager.to_account_info()))
.system_program(&ctx.accounts.system_program.to_account_info())
.plugin(Plugin::PermanentFreezeDelegate(PermanentFreezeDelegate { frozen: true }))
.invoke_signed(&[signer_seeds])?;
Ok(())
}
結論
おめでとうございます!これでAppdataプラグインを使用してチケットソリューションを作成する準備が整いました。CoreとMetaplexについてさらに学びたい場合は、developer hubをチェックしてください。
この記事が気に入ったらサポートをしてみませんか?