#はじめに
本記事ではタイトル通り、Symbolアカウントにメタデータ登録するためのバックエンドをNapkinで自作する方法を解説します。Symbolとはブロックチェーン技術の一つで、APIを使って簡単にデータの取得、または各種SDKを使ってトランザクション等を容易に作成できるブロックチェーンエンジニア以外におすすめするブロックチェーンです。
私の目的はUnityとSymbolを掛け合わせ、ブロックチェーンゲームを作るためにこれを作成しましたが、(C#のSDKは開発中で今はまだない※2021年7月時点)どんな言語でもHttp通信ができれば使えるかと思います。
そもそもの目的は、アクションゲームやRPGなど、何かしらパラメータを持った武器や防具をハクスラのようにランダムで作成、もしくは、何かしらのレアアイテム所持をブロックチェーンに刻む。かつその武器を譲渡や販売などができる仕組み作成のためです。また、譲渡を容易にすることで、そのアイテムが無ければクリアできないステージなどを友達と協力することでクリアが近づくことも可能です。ツヨツヨ武器をプレゼントする、ステージクリアの証明書を自慢する、、など。
もともとの着想を得た記事はこちらになりますのでぜひご一読いただければと思います。
ちょっと事前理解がないとこれから作成するものが何者なのか分からないと思うので、少し説明します。
Symbolではアカウントにメタデータを登録することができます。また、そのメタデータには何かのアカウントから署名することができるので、例えばゲーム本体のアカウントが署名すれば、その武器などはゲームが認めたものとなり、ゲーム上で使用することを許可できます。つまり、メタデータ自体は誰でも登録できますが、ゲームアカウントの署名が無ければ認めなければいい、それだけです。これはゲームに限らず、例えば卒業証書などにも活用できる事例かと思います。
続いて、マルチシグと呼ばれる機能を使えば、その武器アカウントの所有者を決めることができます。武器アカウント自体は意思を持たず、その所有者であるプレイヤーアカウントのみが譲渡等を行えます。
ゲーム側としては、プレイヤーに紐づくマルチシグアカウントを全て取得し、かつゲーム本体が署名した武器や防具を武器庫に入れる、といったことが可能になります。
そして本記事では、ゲーム上で生み出された武器や防具のパラメータ、プレイヤーの秘密鍵を暗号化して送信することで、(ほかにもAPIKeyなど)
・ゲームアカウントの署名付きパラメータをSymbolブロックチェーンに刻む
・その武器アカウントの所有者をプレイヤーとする。
ところまでを解説します。
実は、Symbolの機能であるアグリゲートトランザクションを使えば、これらのトランザクションを一つにまとめ一度のアナウンスで完結することができます。また手数料の支払いもどこかのアカウントにまとめることができます。なお、今回はプレイヤーが全ての手数料を負担することとします。
#準備するもの
■Symbolデスクトップウォレット
こちらの記事を参考にお持ちでない場合はインストールしてください
https://note.com/nembear/n/n56d2c9a28e8a
※ただし、本番稼働するまではテストネットで進めましょう
テストネット上で、
・Gameアカウント
・Playerアカウント
をそれぞれ準備しておきましょう。
■Napkinアカウント
こちらを参考に作成しておいてください
https://paiza.hatenablog.com/entry/2021/07/21/130000
当初、FireBaseFunctionsで作成していましたが、このNapkinに出会い、くっそ簡単にブラウザだけでAPIができたので、こちらを使うことにしました。
Napkinが準備できたら以下のモジュールをインストールしてください
・symbol-sdk@1.0.2 // 最新版(12/23/2021/現在)1.0.3だとエラーが出ます
・crypto
続いて、
・APIKEY
・GAMEPRIVATEKEY
・AESKEY
をそれぞれ環境変数としてNapkinに登録してください。方法は、先ほどのNapkin紹介記事に書かれてあります。
APIKEYは、誰でもこのAPIを使えると良くないのでGETなどでこのAPIキーを渡された場合のみ処理するようにします。特にこだわりはありませんのでパスワード生成ツールなどである程度複雑な文字列を用意してください。
GAMEPRIVATEKEYは、Symbolウォレットからゲームアカウントの秘密鍵を取得し、登録してください。
最後にAESKEYですが、ゲーム側からプレイヤーの秘密鍵をhttp通信で送るので、ちょっと怖いなと思いますし、暗号化して送ります。そのためそれを複合するためのキーで、16桁の英数字で作成してください。
準備が長くなりましたが、以下コードの解説に進みます。
#コード解説
まずは全文を貼り付けますので、この後簡単にブロックごとに解説します。
import * as symbol from 'symbol-sdk-1.0.2';
import * as crypto from 'crypto';
const AESKEY = process.env.AESKEY;
const IV = 'abcdef1234567890'
const key = symbol.KeyGenerator.generateUInt64Key('game');
const nodeUrl = 'https://sym-test-01.opening-line.jp:3001';
const repositoryFactory = new symbol.RepositoryFactoryHttp(nodeUrl);
const networkGenerationHash = '3B5E1FA6445653C971A50687E75E6D09FB30481055E3990C84B25E9222DC1155';
function createCipher(mode) {
return crypto[mode]('aes-128-cbc', KEY, IV)
}
function decrypt(text) {
const buf = Buffer.from(text, 'base64')
const cipher = createCipher('createDecipheriv')
const decrypted = cipher.update(buf);
return Buffer.concat([decrypted, cipher.final()]).toString('utf-8')
}
export default async (req, res) => {
if(req.query.apikey !== process.env.APIKEY) {
throw TypeError("Wrong APIKEY ERROR");
} else {
let networkType = symbol.NetworkType.MAIN_NET;
if (req.query.mode == "TEST_NET") {
networkType = symbol.NetworkType.TEST_NET;
}
const gamePrivateKey = process.env.GAMEPRIVATEKEY;
const gameAccount = symbol.Account.createFromPrivateKey(gamePrivateKey, networkType);
const playerPrivatekey = decrypt(req.query.pkey);
const playerAccount = symbol.Account.createFromPrivateKey(playerPrivatekey, networkType);
const weaponAccount = symbol.Account.generateNewAccount(networkType);
const type = req.query.type;
const id = req.query.id;
const value = JSON.parse(req.query.value);
const metaData = JSON.stringify({
"type": type,
"id": id,
"value": value
})
const epochAdjustment = await repositoryFactory
.getEpochAdjustment()
.toPromise()
const multisigAccountModificationTransaction = symbol.MultisigAccountModificationTransaction.create(
symbol.Deadline.create(epochAdjustment),
1,
1,
[playerAccount.address],
[],
networkType,
);
const gameAccountMetadataTransaction = symbol.AccountMetadataTransaction.create(
symbol.Deadline.create(epochAdjustment),
weaponAccount.address,
key,
metaData.length,
metaData,
networkType,
)
const aggregateTransaction = symbol.AggregateTransaction.createComplete(
symbol.Deadline.create(epochAdjustment),
[
multisigAccountModificationTransaction.toAggregate(weaponAccount.publicAccount),
gameAccountMetadataTransaction.toAggregate(gameAccount.publicAccount)
],
networkType,
[],
symbol.UInt64.fromUint(2000000),
);
const signedTransactionNotComplete = playerAccount.sign(
aggregateTransaction,
networkGenerationHash,
);
const cosignedTransactionWeapon = symbol.CosignatureTransaction.signTransactionPayload(
weaponAccount,
signedTransactionNotComplete.payload,
networkGenerationHash,
);
const cosignedTransactionGame = symbol.CosignatureTransaction.signTransactionPayload(
gameAccount,
signedTransactionNotComplete.payload,
networkGenerationHash,
);
const cosignatureSignedTransactions = [
new symbol.CosignatureSignedTransaction(
cosignedTransactionGame.parentHash,
cosignedTransactionGame.signature,
cosignedTransactionGame.signerPublicKey,
),
new symbol.CosignatureSignedTransaction(
cosignedTransactionWeapon.parentHash,
cosignedTransactionWeapon.signature,
cosignedTransactionWeapon.signerPublicKey,
),
];
const rectreatedAggregateTransactionFromPayload = symbol.TransactionMapping.createFromPayload(
signedTransactionNotComplete.payload,
);
const signedTransactionComplete = playerAccount.signTransactionGivenSignatures(
rectreatedAggregateTransactionFromPayload,
cosignatureSignedTransactions,
networkGenerationHash,
);
const transactionHttp = repositoryFactory.createTransactionRepository();
const result = await transactionHttp.announce(signedTransactionComplete).toPromise();
console.log(result)
}
}
以上が全文です。そのまま貼り付けても使えるかとは思います。
PostManなどで動作確認をしてみてください。
ただし、Playerの秘密鍵を復号(関数decrypt)することが前提なので暗号化せずにテストする場合は、
const playerPrivatekey = req.query.pkey;
のようにしてそのまま秘密鍵を受け取るようにしてください。
また私の場合は、type
id
value
の3つのパラメータを受け取り、それを文字列に変換しメタデータとして登録しましたので、そこは自由に変更していただければと思います。このまま使う場合はパラメータは
pkey
プレイヤーのプライベートキー暗号化、もしくは生のまま
apikey
環境変数で設定したAPIKEY
mode
メインネットかテストネットで使用するか
type
ゲームの仕様によりますが、私の場合は例えばweapon,item,certificateなどを想定しています。
id
同じく仕様次第です。例えば武器名やクリアしたステージ名など。
value
こちらは武器の攻撃力など。射程距離など2つ以上の場合は、JSONで渡します。
例)
"value": {
"power": 58,
"reach": 5.12
}
このように英数字以外を使用する場合、URLエンコードをしてから送信することを忘れずに。
以下、簡単にブロックごとに解説。
import * as symbol from 'symbol-sdk-1.0.2';
import * as crypto from 'crypto';
const AESKEY = process.env.AESKEY;
const IV = 'abcdef1234567890'
const key = symbol.KeyGenerator.generateUInt64Key('game');
const nodeUrl = 'https://sym-test-01.opening-line.jp:3001';
const repositoryFactory = new symbol.RepositoryFactoryHttp(nodeUrl);
const networkGenerationHash = '3B5E1FA6445653C971A50687E75E6D09FB30481055E3990C84B25E9222DC1155';
使用するモジュールはsymbol-sdkとcryptoのみ。
復号のためのAESKEYは環境変数にあります。
IV
とは暗号化、復号するときの方向?らしいです。これは公開されても良いもので、暗号化するときと復号は同一のIVである必要があります。
const key = symbol.KeyGenerator.generateUInt64Key('game');
Symbolのメタデータ登録はKEYとVALUEがセットで登録でき、KEYは今回は統一しています。これは使い方を考えれば色々できそうですが、自分で使う時に極力シンプルに使いたくこのようにしました。
NODEはOpeningLine社のテストネットをお借りしています。
function createCipher(mode) {
return crypto[mode]('aes-128-cbc', KEY, IV)
}
function decrypt(text) {
const buf = Buffer.from(text, 'base64')
const cipher = createCipher('createDecipheriv')
const decrypted = cipher.update(buf);
return Buffer.concat([decrypted, cipher.final()]).toString('utf-8')
}
こちらは復号のための関数です。
if(req.query.apikey !== process.env.APIKEY) {
throw TypeError("Wrong APIKEY ERROR");
} else {
let networkType = symbol.NetworkType.MAIN_NET;
if (req.query.mode == "TEST_NET") {
networkType = symbol.NetworkType.TEST_NET;
}
const gamePrivateKey = process.env.GAMEPRIVATEKEY;
const gameAccount = symbol.Account.createFromPrivateKey(gamePrivateKey, networkType);
const playerPrivatekey = decrypt(req.query.pkey);
const playerAccount = symbol.Account.createFromPrivateKey(playerPrivatekey, networkType);
const weaponAccount = symbol.Account.generateNewAccount(networkType);
const type = req.query.type;
const id = req.query.id;
const value = JSON.parse(req.query.value);
const metaData = JSON.stringify({
"type": type,
"id": id,
"value": value
})
APIキーが正しく無ければエラーを返します。
APIキーが正しければ、テストネットかメインネットを判別します。デフォルトはメインネットにしています。
それぞれ、渡されたパラメータを変数に格納します。その際に、ゲームアカウントとプレイヤーアカウントは秘密鍵から作成し、武器アカウントは新たに作成しています。
さて、ここからはSymbolの話です。正直、私も理解するまでに時間はかかりましたが、それでもやってることの凄さを考えれば非常にシンプルだと思います。特に最初は理解しがたいことも多いと思いますが、根気よく以下WEBサイトとにらめっこしていれば段々理解できてきますので、ぜひ何度も読まれることをおすすめします。とは言え、ちんぷんかんぷんで手を出さないぐらいなら、読むのをやめてコピペで遊ぶことを推奨します、もちろんテストネットで!!
↑ガイドのあたりおすすめ
const multisigAccountModificationTransaction = symbol.MultisigAccountModificationTransaction.create(
symbol.Deadline.create(epochAdjustment),
1,
1,
[playerAccount.address],
[],
networkType,
);
まずはプレイヤーアカウントが武器アカウントの署名者になるのですが、(武器アカウントをマルチシグアカウントとする)そのための準備です。この段階ではプレイヤーアカウントが一人だけとあるアカウントの署名者になるよ!って宣言しているイメージです。そのとあるアカウントはのちほど決定します。
const gameAccountMetadataTransaction = symbol.AccountMetadataTransaction.create(
symbol.Deadline.create(epochAdjustment),
weaponAccount.address,
key,
metaData.length,
metaData,
networkType,
)
武器アカウントに対してメタデータを登録します。読めば理解できる箇所。ただし署名者は↑と同じく後ほど。
const aggregateTransaction = symbol.AggregateTransaction.createComplete(
symbol.Deadline.create(epochAdjustment),
[
multisigAccountModificationTransaction.toAggregate(weaponAccount.publicAccount),
gameAccountMetadataTransaction.toAggregate(gameAccount.publicAccount)
],
networkType,
[],
symbol.UInt64.fromUint(2000000),
);
さて、上の2つのトランザクションを一つのトランザクションにまとめるためにアグリゲートトランザクションとします。ここがSymbolの強みの一つで、最大100個のトランザクションをとりまとめることができます。(記事を探すとだいたい1000ってなってるけど多分100が最大)
上の2つのトランザクションを配列で格納していますが、それぞれ署名者を引数としていることを確認してください。また、この段階では正しくは署名していません。publicAccountとなっています。つまり、ここまでのトランザクションは秘密鍵を知っていなくても誰でもできます。
ちょっとここからは私も理解度が100%じゃないところに入っていきます。フォローできる方がいればフォローして欲しい。。。特に言葉としての表現が正しいかが不安。
const signedTransactionNotComplete = playerAccount.sign(
aggregateTransaction,
networkGenerationHash,
);
const cosignedTransactionWeapon = symbol.CosignatureTransaction.signTransactionPayload(
weaponAccount,
signedTransactionNotComplete.payload,
networkGenerationHash,
);
const cosignedTransactionGame = symbol.CosignatureTransaction.signTransactionPayload(
gameAccount,
signedTransactionNotComplete.payload,
networkGenerationHash,
);
上記3つはそれぞれのアカウントの署名。
一番上は先ほど作成したアグリゲートトランザクションに対して手数料を負担するプレイヤーの署名。
下2つは武器アカウントとゲームアカウントがプレイヤーアカウントが署名した不完全なトランザクションに署名。つまりこの時点でここで出てくる3つのアカウントの署名が揃ったことになります。
const cosignatureSignedTransactions = [
new symbol.CosignatureSignedTransaction(
cosignedTransactionGame.parentHash,
cosignedTransactionGame.signature,
cosignedTransactionGame.signerPublicKey,
),
new symbol.CosignatureSignedTransaction(
cosignedTransactionWeapon.parentHash,
cosignedTransactionWeapon.signature,
cosignedTransactionWeapon.signerPublicKey,
),
];
const rectreatedAggregateTransactionFromPayload = symbol.TransactionMapping.createFromPayload(
signedTransactionNotComplete.payload,
);
const signedTransactionComplete = playerAccount.signTransactionGivenSignatures(
rectreatedAggregateTransactionFromPayload,
cosignatureSignedTransactions,
networkGenerationHash,
);
ここで最後にプレイヤーアカウントの管理のもと、全てのトランザクションをコンプリートとしてぜーんぶひとまとめになりました。(手数料についてはここでのアカウントが負担するのかもしれない、、)
signedTransactionComplete
これですね。
const transactionHttp = repositoryFactory.createTransactionRepository();
const result = await transactionHttp.announce(signedTransactionComplete).toPromise();
console.log(result)
最後にネットワークにアナウンスしておしまいです。
承認されれば、
・武器アカウントを作成し、プレイヤーアカウント管理のもとマルチシグアカウントとする
・武器アカウントに、攻撃力などのメタデータが、ゲームアカウントの署名のもと登録される
これらが実現します。
#さいごに
以上、こちらの記事を終えます。おそらく間違っている表現などがあるかもしれませんので随時加筆修正したいと思います。ちょっと不安に思っているのはセキュリティ面です。まず、プレイヤーについては多くXYMを所持しないことは大切かと思いますし、そのあたりもっといい方法を思いつく方がいらっしゃいましたらぜひアドバイスいただければ幸いです。
また、こちらで登録したメタデータをどのように取得するかは次回の記事にします。あとは、ゲーム上で取得した武器のパラメータをこのAPIに投げる。そして、登録された武器を次回書く記事を使って取得すればゲームとブロックチェーンの融合は最低限完成します。
なお、もしC#のSDKが開発されれば、バックエンドをわざわざ作成する必要はなく、Unity内のみでこれらが完結する予定です。そもそも自分で必要な箇所だけを開発すればいいのかもしれませんが私にそんなスキルはありません。
この記事にたどり着いた方がいらっしゃいましたら、自分なりの活用を色々考えてみてください。
もし、あるステージをクリアした方のみが割引を受けられるサービスがあったら面白いと思いませんか?このゲームで取得した武器のパラメータやレア度を違うゲームで引き継げたら?またはリアルとの融合で、脱出ゲームやその他ゲームで使えたら?
SymbolならHttp通信さえできれば、データの取得は非常に容易なため、こういった派生は比較的簡単にできるかと思います。