はじめに
下記の仕組みを作ってみました。
- 元気玉をイメージした、ブロックチェーンで送信する際に複数の署名が必要な形式のトランザクションを利用した仕組み(元気玉トランザクション)
- 集まっている署名の数に応じて画像(今回は🍅)サイズが変動する仕組み(サイズ可変式トマト)
- 自身の管理していないコンテンツから、自身の管理しているコンテンツに影響を与える仕組み(外部コンテンツからの干渉)
背景
Symbolブロックチェーン界隈にはトマトを模したデジタルトークンをユーザが作成・配布し、デジタルトマトトークンをブロックチェーンのトランザクションとして投げつけたり(送信)、トマトを投げつけるようなゲームをプレイする事ができるお祭りがあります。(スペインのトマトを投げ合う収穫祭のラ・トマティーナを模したもの)
このお祭りに合わせて、自身の開発しているゲームにて複数人でトマトを大きくしてボスにぶつけて攻撃するというコンセプトでイベントをやってみたいと思い、今回の仕組みを作りました。
使ったもの
ブロックチェーン:Symbol
Webサーバ: Node.js+TypeScript+Express
用語
- マルチシグ:あるアカウントでトランザクションを送信する際に別アカウント(複数可)の署名が無いと送信できないようにする仕組み
- アグリゲートボンデッドトランザクション:送信に複数の署名を必要とする形式のトランザクション
- トークン回収トランザクション:指定したトークンを送信先から回収する形式のトランザクション
元気玉トランザクション
概要
元気玉を作って投げることをイメージして、送信に複数の署名が必要なアグリゲートボンデッドトランザクションを利用して署名を集めてトランザクションを作り、ゲームのボスのHPを管理しているアカウントに対して送信(攻撃)して残高=ゲーム内HPとしているトークンを管理者アカウントから回収(ダメージ)しました。
流れ
- 事前に管理者アカウントを含む複数のユーザアカウントでマルチシグを組む
- ブロックチェーンのブロック高が特定のブロック高に到達する事を条件にアグリゲートボンデッドトランザクションを起案
- ユーザに各々のタイミングで署名してもらう
- 規定の署名数が集まったらトランザクション送信(元気玉🍅投げ)
- 送信先に着金を条件に管理者アカウントからトークン回収トランザクションでHP管理用トークンを回収(HP減算扱い)
概要図
簡易版
詳細版
コード
①~④の部分
━━━━━━━━━━━━━━━━━━╮
┃ ソースコードを表示(折りたたみ) ┃
╰━━━━━━━━━━━━━━━━━━╯
import {
Account,
Address,
AggregateTransaction,
Deadline,
HashLockTransaction,
Mosaic,
MosaicId,
NetworkType,
NewBlock,
PlainMessage,
PublicAccount,
RepositoryFactoryHttp,
TransferTransaction,
UInt64
} from "symbol-sdk";
import { Config as C, logger } from '../init';
import { sleep } from "../utils/Util";
const node = `${C.conf.symbol.node.host}:${C.conf.symbol.node.port}`;
const epochAdjustment = C.conf.symbol.node.epochAdjustment;
const generationHash = C.conf.symbol.node.generationHash;
const tomatoMosaicId = C.conf.symbol.mosaics.tomato;
const currencyMosaicId = C.conf.symbol.mosaics.currency;
let networkType: NetworkType;
if (process.env.STAGE === "prod" || process.env.STAGE === "staging") {
networkType = NetworkType.MAIN_NET;
} else {
networkType = NetworkType.TEST_NET;
}
const repoFac = new RepositoryFactoryHttp(node);
const txRepo = repoFac.createTransactionRepository();
const netRepo = repoFac.createNetworkRepository();
let medianFeeMultiplier: number;
// マルチシググループ1
const group1Information = {
proposalAccountPrivateKey: process.env.TEAM1_PROPOSAL_PRIVATE_KEY as string,
attackerAccountPrivateKey: process.env.TEAM1_ATTACKER_PRIVATE_KEY as string,
attackBlocks: [750179, 750200, 752580, 752609, 758820, 758821, 758822, 764306, 2577373]
}
// マルチシググループ2
const group2Information = {
proposalAccountPrivateKey: process.env.TEAM1_PROPOSAL_PRIVATE_KEY as string,
attackerAccountPrivateKey: process.env.TEAM2_ATTACKER_PRIVATE_KEY as string,
attackBlocks: [750179, 750200, 752580, 752609, 752702, 764279, 764306, 2577375]
}
// 攻撃対象ボスアドレス
const bossAddress: string = C.conf.symbol.addresses.boss;
// 送信するトークン数量
const attackAmount: number = 1000;
// groupごとの処理
const group1 = (block: NewBlock) => {
const proposalAccount: Account = Account.createFromPrivateKey(group1Information.proposalAccountPrivateKey, networkType);
const attackerAccount: Account = Account.createFromPrivateKey(group1Information.attackerAccountPrivateKey, networkType);
attackChance(1, block, proposalAccount, attackerAccount.publicAccount, group1Information.attackBlocks);
}
const group2 = (block: NewBlock) => {
const proposalAccount: Account = Account.createFromPrivateKey(group2Information.proposalAccountPrivateKey, networkType);
const attackerAccount: Account = Account.createFromPrivateKey(group2Information.attackerAccountPrivateKey, networkType);
attackChance(2, block, proposalAccount, attackerAccount.publicAccount, group2Information.attackBlocks);
}
/**
* 連署発生ブロックか判定する
* @param block
* @returns
*/
const isAttackBlock = (block: NewBlock, attackBlocks: number[]) => {
if (attackBlocks.includes(block.height.compact())) {
return true;
}
return false;
}
/**
* ブロックが攻撃ブロックだったら攻撃チャンスのアグボン実行
* @param groupNumber
* @param block
* @param proposalAccount
* @param attackerPublicAccount
* @param attackBlocks
*/
const attackChance = (groupNumber: number, block: NewBlock, proposalAccount: Account, attackerPublicAccount: PublicAccount, attackBlocks: number[]) => {
if (isAttackBlock(block, attackBlocks)) {
logger.log(`block: ${block.height.compact()} groupNumber${groupNumber} doAttack!!`);
doAttack(groupNumber, proposalAccount, attackerPublicAccount);
}
}
/**
* アグボン攻撃実行
* @param signer
* @param publicAccount
*/
const doAttack = async (groupNumber: number, signer: Account, publicAccount: PublicAccount) => {
// 攻撃用TransferTransacion作成
const tx: TransferTransaction = TransferTransaction.create(
Deadline.createEmtpy(),
Address.createFromRawAddress(bossAddress),
[
new Mosaic(
new MosaicId(tomatoMosaicId),
UInt64.fromUint(attackAmount)
)
],
PlainMessage.create(`Group${groupNumber} Tomato Attack!!`),
networkType
);
// アグリゲートで包む
const aggregateTx: AggregateTransaction = AggregateTransaction.createBonded(
Deadline.create(epochAdjustment, 48),
[
tx.toAggregate(publicAccount)
],
networkType,
[]
).setMaxFeeForAggregate(medianFeeMultiplier, 2);
// アグリゲート署名
const signedAggregateTx = signer.sign(aggregateTx, generationHash);
// アグリゲートに紐づくハッシュロックTx作成
const hashLockTx = HashLockTransaction.create(
Deadline.create(epochAdjustment),
new Mosaic(
new MosaicId(currencyMosaicId),
UInt64.fromUint(10 * 1000000)
),
UInt64.fromUint(5760),
signedAggregateTx,
networkType
).setMaxFee(medianFeeMultiplier);
// ハッシュロック署名
const signedLocktx = signer.sign(hashLockTx, generationHash);
logger.log("Annouce Hashlock");
await txRepo.announce(signedLocktx).toPromise();
logger.log(`txHash: ${signedLocktx.hash}`);
logger.log(`${node}/transactionStatus/${signedLocktx.hash.toString()}`);
await sleep(90 * 1000);
logger.log("Annouce Bonded");
await txRepo.announceAggregateBonded(signedAggregateTx).toPromise();
logger.log(`txHash: ${signedAggregateTx.hash}`);
logger.log(`${node}/transactionStatus/${signedAggregateTx.hash.toString()}`);
}
// 規定ブロックになったら連署攻撃のアグボンを発行するデーモン
(async () => {
medianFeeMultiplier = (await netRepo.getTransactionFees().toPromise())!.medianFeeMultiplier;
// WebSocketでブロック監視開始
const listener = repoFac.createListener();
listener.open().then(() => {
logger.debug("listener open");
listener.newBlock().subscribe((block: NewBlock) => {
group1(block);
group2(block);
})
});
})();
⑨,⑩の部分
━━━━━━━━━━━━━━━━━━╮
┃ ソースコードを表示(折りたたみ) ┃
╰━━━━━━━━━━━━━━━━━━╯
import {
Account,
Address,
AggregateTransaction,
Deadline,
Mosaic,
MosaicId,
MosaicSupplyRevocationTransaction,
NetworkType,
NewBlock,
RepositoryFactoryHttp,
TransactionGroup,
TransactionType,
TransferTransaction,
UInt64
} from "symbol-sdk";
import {Config as C, logger} from '../init';
import { getAccountInformation } from "../utils/AccountInformation";
const node = `${C.conf.symbol.node.host}:${C.conf.symbol.node.port}`;
const epochAdjustment = C.conf.symbol.node.epochAdjustment;
const generationHash = C.conf.symbol.node.generationHash;
let networkType: NetworkType;
if (process.env.STAGE === "prod" || process.env.STAGE === "staging") {
networkType = NetworkType.MAIN_NET;
} else {
networkType = NetworkType.TEST_NET;
}
// 回収対象トークンID
const revokeMosaicId = C.conf.symbol.mosaics.hitpoint;
// 攻撃対象ボスアドレス
const bossAddress = C.conf.symbol.addresses.boss;
// ダメージ(トークン回収数)初期値
const defaultRevokeAmount = 1000;
const repoFac = new RepositoryFactoryHttp(node);
const txRepo = repoFac.createTransactionRepository();
const netRepo = repoFac.createNetworkRepository();
let medianFeeMultiplier: number;
const proposalAddresses: string[] = [
C.conf.symbol.addresses.proposal1,
];
/**
* HPトークンの残高状況を取得
* @returns
*/
const getMaxRevokeAmount = async (): Promise<number> => {
// 攻撃対象のアカウント情報を取得
const account = await getAccountInformation(bossAddress);
const hpMosaic: Mosaic[] = account.mosaics.filter((mosaic) => {
console.log(mosaic);
// HPモザイクの保有状況をチェック
if(mosaic.id.toHex() === revokeMosaicId){
return true;
}
return false;
});
return hpMosaic ? hpMosaic[0].amount.compact() : 0;
}
/**
* HPトークン回収トランザクション実行
*/
const revoke = async () => {
// トークン回収トランザクション発行元アカウント情報を復元
const revokerPrivateKey = process.env.MASTER_PRIVATE_KEY as string;
const revokerAccount = Account.createFromPrivateKey(revokerPrivateKey, networkType);
let revokeAmount = defaultRevokeAmount;
// 回収可能なHPトークン数量を取得
const hasRevokeMosaicAmount = await getMaxRevokeAmount();
// 保有量以上を回収しようとすると失敗するため保有数量を上限として回収する
if (hasRevokeMosaicAmount < revokeAmount) {
revokeAmount = hasRevokeMosaicAmount;
}
console.log(`revokeAmount: ${revokeAmount}`);
const revokeMosaic = new Mosaic(new MosaicId(revokeMosaicId), UInt64.fromUint(revokeAmount));
// トークン回収トランザクション作成
const tx = MosaicSupplyRevocationTransaction.create(
Deadline.create(epochAdjustment),
Address.createFromRawAddress(bossAddress),
revokeMosaic,
networkType
).setMaxFee(medianFeeMultiplier);
// 署名してブロードキャスト
const signedTx = revokerAccount.sign(tx, generationHash);
const res = await txRepo.announce(signedTx).toPromise();
logger.log(`${res}`);
logger.log(`txHash: ${signedTx.hash}`);
logger.log(`${node}/transactionStatus/${signedTx.hash.toString()}`);
}
/**
* 起案者アドレスか判定する
* @param address
* @returns
*/
const isProposalAddress = (address: string): boolean => {
return proposalAddresses.includes(address);
}
/**
* 起案者(署名者)が起案役のトランザクションを検知してボスアカウントからHPトークンを回収する
* @param block
*/
const newBlock = async (block: NewBlock) => {
txRepo.search({
address: Address.createFromRawAddress(bossAddress),
height: block.height,
group: TransactionGroup.Confirmed,
}).subscribe(async (_)=>{
if(_.data.length > 0) {
// 入ってるTxの数分処理する
const transaction:any[] = _.data;
for (const tx of transaction) {
console.dir(tx, {depth:null})
// aggregateの中のtransactionInfoを拾ってくる必要がある
// 起案者(署名者)が起案役アドレスかつ宛先がボスアドレスかつのtxを拾う
if (tx.type == TransactionType.AGGREGATE_BONDED && isProposalAddress(tx.signer.address.address)){
// アグリゲートの先頭のインナートランザクションの送信先がボスアドレスか判定する
const aggTx = await txRepo.getTransactionsById([(tx.transactionInfo!.hash) as string], TransactionGroup.Confirmed).toPromise() as unknown as AggregateTransaction[];
if ((aggTx[0].innerTransactions[0] as TransferTransaction).recipientAddress.plain() === bossAddress) {
// HPトークン回収実行
revoke();
}
}
}
}
});
}
// 起案者が指定アカウントかつアグリゲートにボス宛のTransferTransactionが入っていたらrevokeするデーモン
(async () => {
medianFeeMultiplier = (await netRepo.getTransactionFees().toPromise())!.medianFeeMultiplier;
// WebSocketでブロック監視開始
const listener = repoFac.createListener();
listener.open().then(()=> {
logger.debug("listener open");
listener.newBlock().subscribe((block: NewBlock) => {
newBlock(block)
})
});
})();
サイズ可変式トマト
概要
送信未完了状態(署名集め中)の元気玉トランザクションのアグリゲートボンデッドに集まっている署名の数に応じて、Webサイト上とゲーム内の🍅の画像サイズを変動させました。
ゲームとWebサイトから叩くAPIは共通。
簡易版
詳細版
コード
指定アカウントの起案のトランザクションの署名数をブロックチェーンから取得するAPI
━━━━━━━━━━━━━━━━━━╮
┃ ソースコードを表示(折りたたみ) ┃
╰━━━━━━━━━━━━━━━━━━╯
import express from 'express';
import bodyParser from 'body-parser';
import { Config as C } from '../init';
import {
Address,
RepositoryFactoryHttp,
TransactionGroup,
} from "symbol-sdk";
export const tomato = express.Router();
tomato.use(express.json());
tomato.use(bodyParser.json());
// 参照ノード
const NODE_URL = "https://00fabf14.xym.stir-hosyu.com:3001";
// 攻撃トランザクション起案者のアドレス
const attackerAddresses: string[] = [C.conf.symbol.addresses.attacker1, C.conf.symbol.addresses.attacker2];
/**
* トランザクション起案者が規定アドレスになっているトランザクションの連署数を取得する
* @param repoFac
* @param address
* @returns
*/
const getCosignatureNumber = async (repoFac: RepositoryFactoryHttp, address: string): Promise<number> => {
const txRepo = repoFac.createTransactionRepository();
let pageNumber = 1;
const cosignatures: any = [];
while (true) {
const result = await txRepo
.search({
group: TransactionGroup.Partial,
pageNumber: pageNumber,
embedded: true,
address: Address.createFromRawAddress(address),
})
.toPromise();
for (let datum of result?.data!) {
// @ts-ignore
for (let cosignature of datum.cosignatures) {
cosignatures.push(cosignature as never);
}
}
if (result?.isLastPage) break;
pageNumber++;
}
return cosignatures.length;
}
/**
* トランザクション起案者が規定アドレスになっているトランザクションの連署数の合計値を返す
*/
tomato.get('/cosignature', async function (request, response) {
try {
const repo = new RepositoryFactoryHttp(NODE_URL);
let cosignatureCount = 0;
for (let attackerAddress of attackerAddresses) {
cosignatureCount += await getCosignatureNumber(repo, attackerAddress);
}
console.log(`cosignatureCount: ${cosignatureCount}`);
response.json({ count: cosignatureCount});
} catch (e){
console.dir(e);
response.json({message:"error!!"});
}
});
外部コンテンツからの干渉
概要
トマトを模したトークンを投げ合うマルチプレイ対応ゲーム。
投げたトマトが相手に当たるとトマトトークンを送信するトランザクションが発生する(事前に送信者の秘密鍵登録が必要)
上記のゲームに自身開発ゲームのボスのアカウントで参加し、トマトをぶつけられる度に自身のゲームの管理者アカウントからボスHPを管理しているトークンを回収するトランザクションを送ることで外部コンテンツである上記ゲームから自身開発のゲームに影響を与えられるようにしました。
簡易版
詳細版
コード
③~⑥の部分
━━━━━━━━━━━━━━━━━━╮
┃ ソースコードを表示(折りたたみ) ┃
╰━━━━━━━━━━━━━━━━━━╯
import {
Account,
Address,
Deadline,
Mosaic,
MosaicId,
MosaicSupplyRevocationTransaction,
NetworkType,
NewBlock,
RepositoryFactoryHttp,
TransactionGroup,
TransactionType,
UInt64
} from "symbol-sdk";
import {Config as C, logger} from '../init';
import { getAccountInformation } from "../utils/AccountInformation";
const node = `${C.conf.symbol.node.host}:${C.conf.symbol.node.port}`;
const epochAdjustment = C.conf.symbol.node.epochAdjustment;
const generationHash = C.conf.symbol.node.generationHash;
let networkType: NetworkType;
if (process.env.STAGE === "prod" || process.env.STAGE === "staging") {
networkType = NetworkType.MAIN_NET;
} else {
networkType = NetworkType.TEST_NET;
}
// 回収対象トークンID
const revokeMosaicId = C.conf.symbol.mosaics.hitpoint;
// 攻撃対象ボスアドレス
const bossAddress = C.conf.symbol.addresses.boss;
// ダメージ(トークン回収数)初期値(トマティーナ広場は10/1回)
const defaultRevokeAmount = 10;
const repoFac = new RepositoryFactoryHttp(node);
const txRepo = repoFac.createTransactionRepository();
const netRepo = repoFac.createNetworkRepository();
let medianFeeMultiplier: number;
/**
* HPトークンの残高状況を取得
* @returns
*/
const getMaxRevokeAmount = async (): Promise<number> => {
const account = await getAccountInformation(bossAddress);
const hpMosaic: Mosaic[] = account.mosaics.filter((mosaic) => {
console.log(mosaic);
// HPトークンの保有状況をチェック
if(mosaic.id.toHex() === revokeMosaicId){
return true;
}
return false;
});
return hpMosaic ? hpMosaic[0].amount.compact() : 0;
}
/**
* HPトークン回収トランザクション実行
*/
const revoke = async () => {
// トークン回収トランザクション発行元アカウント情報を復元
const revokerPrivateKey = process.env.MASTER_PRIVATE_KEY as string;
const revokerAccount = Account.createFromPrivateKey(revokerPrivateKey, networkType);
let revokeAmount = defaultRevokeAmount;
// 回収可能なHPトークン数量を取得
const hasRevokeMosaicAmount = await getMaxRevokeAmount();
// 保有量以上を回収しようとすると失敗するため保有数量を上限として回収する
if (hasRevokeMosaicAmount < revokeAmount) {
revokeAmount = hasRevokeMosaicAmount;
}
console.log(`revokeAmount: ${revokeAmount}`);
const revokeMosaic = new Mosaic(new MosaicId(revokeMosaicId), UInt64.fromUint(revokeAmount));
// トークン回収トランザクション作成
const tx = MosaicSupplyRevocationTransaction.create(
Deadline.create(epochAdjustment),
Address.createFromRawAddress(bossAddress),
revokeMosaic,
networkType
).setMaxFee(medianFeeMultiplier);
// 署名してブロードキャスト
const signedTx = revokerAccount.sign(tx, generationHash);
const res = await txRepo.announce(signedTx).toPromise();
logger.log(`${res}`);
logger.log(`txHash: ${signedTx.hash}`);
logger.log(`${node}/transactionStatus/${signedTx.hash.toString()}`);
}
/**
* 攻撃対象ボスアカウントへのトランザクションを検知してボスアカウントからHPトークンを回収する
* @param block
*/
const newBlock = async (block: NewBlock) => {
txRepo.search({
address: Address.createFromRawAddress(bossAddress),
height: block.height,
group: TransactionGroup.Confirmed,
}).subscribe(async (_)=>{
if(_.data.length > 0) {
// 入ってるTxの数分処理する
const transaction:any[] = _.data;
for (const tx of transaction) {
console.dir(tx, {depth:null})
// 宛先がボスアドレスかつのtxを拾う
if (tx.type == TransactionType.TRANSFER){
console.dir(tx, {depth:null})
revoke();
}
}
}
});
}
// ボス宛のTransferTransactionごとにrevokeするデーモン
(async () => {
medianFeeMultiplier = (await netRepo.getTransactionFees().toPromise())!.medianFeeMultiplier;
// WebSocketでブロック監視開始
const listener = repoFac.createListener();
listener.open().then(()=> {
logger.debug("listener open");
listener.newBlock().subscribe((block: NewBlock) => {
newBlock(block)
})
});
})();
プログラム概要
今回の紹介した仕組みの主要な部分のプログラム概要について下記に記します。
ブロック高の監視やトランザクションの受信の検知などはブロックチェーンノードにWebSocketで繋いで行っています。
名前 | 概要 | 使用場面/振り返り |
---|---|---|
マルチシグ構築するやつ | 入力したアドレスに対してマルチシグへの参加を要求するトランザクションを送信する。 | 参加者同士のマルチシグを組む時。ウォレット画面上で操作するのは手間なので、プログラムにアドレスを書いて実行すれば良いのでいざ組み始めた時に素早く組めた。書くのは大変だった。 |
アグリゲートボンデッドトランザクションを発行するやつ | ブロック高を監視し、設定したブロック高に到達した時に指定のアドレスへアグリゲートボンデッドトランザクションを送信する。 | ボスへの連署トマト投げ攻撃発動時。イベント当日はガッツリ仕事で手動で発動はしていられないため、自動で攻撃を発動させるこの仕組みは必須だった。 |
トークン回収トランザクションを発行するやつ | 特定のアドレスへのトランザクションを検知した時に特定のアドレスへモザイク回収トランザクションを送信する。 | トークン数量で管理されたボスのHPを減らす時。シンプルに送金=ダメージでもボス討伐の仕組みは実現できたがExplorer等外部ツールでボスHPを確認したりできる方がパブリックブロックチェーン感があって好みのためトークン数量=残HPとした。 |
署名数を取得するやつ | 特定のアドレスが起案者となっているトランザクションの署名数を取得する。 | ゲーム内外で表示するトマトの大きさの元情報。今回の仕組みの肝。元々マルチプレイ対応のゲームなので協力を見える化したい想いが強かった。 |
まとめ
元気玉をイメージした、ブロックチェーンで複数の署名を揃えてトランザクションを送信、その送信先のHPを模したトークン残高を減らす仕組みを作ってみました。また併せて、自身の管理してない外部コンテンツから、自身の管理しているコンテンツに影響を与える仕組みを作りました。
ブロックチェーンはデータの改ざんが困難である事からユースケースとしてトレーサビリティが有名ですが、今回は署名状況(元気玉の進捗度合い)を外部サイトで確認できたり、外部コンテンツから影響を与えるといった、ブロックチェーンをパブリックデータベースとして活用し、複数人で協力してゲームのボスを倒すというコンセプトで開発を行いました。結果、イベント当日に仕組みがうまく機能し、コンセプト通りのイベントの開催ができたと思います。
高額なNFTやトークンの暴騰・暴落が話題になりがちなブロックチェーン周りですが、今回のような無価値なトークンやパブリックデータベースとしての使い方がもっと広まって相互に作用する世界になったら良いなと思います。
引用
SymbolCommunityWeb