0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

3週間でブロックチェーンゲームを作ってみた(Mainnetリリースまで編)

Last updated at Posted at 2025-07-11

画面上部にゲームのユーザ情報を出します。

ゲーム画面上部に以下のように記述することで

  {#if havingResource}
    <p class="paragraph sticky">
      <span class="allura">Hello,</span>{havingResource?.nickname}
      <span class="total-prize">
      ( Total prize won: ₣
        <span class="cinzel balance">
          {currentSituation?.prizeWinners[havingResource?.gamerId] ?? 0}
        </span>
      )</span>
    </p>
  {/if}

以下のようにユーザ情報を出しました。

image.png

ゲームにコインを投入するロジック

ゲームをスタートするメソッドをスマートコントラクトに追加します。

resource Gamerの中に以下を追記します。

    access(all) fun insert_coin(payment: @FlowToken.Vault) {
      pre {
        payment.balance == 1.1: "payment is not 1.1FLOW coin."
      }
      if let challenged = TestnetTest2.gamersInfo.tryingPrize[self.gamerId] {
        if (challenged > 0) {
          panic("You are now on playing the game. Payment is not accepted.")
        }
      }
      TestnetTest2.gamersInfo.setTryingPrize(gamerId: self.gamerId)
      TestnetTest2.FlowTokenVault.borrow()!.deposit(from: <- payment)
    }

一番下のコードでは前回の記事で追加した金庫(vault)に、ユーザに支払ってもらったゲーム料金を格納しています。スマートコントラクトの中ではこの1行だけでお金を集金できるんですね。

これを flow project deploy --update --network testnet コマンドでデプロイします。

(テストネットであればhttps://flow-view-source.comで自分がデプロイしたコードを見ることができます)

/flow_blockchain/transactions.js に以下を追記します。

export const insertCoin = async function () {
  const txId = await mutate({
    cadence: `
      import "TestnetTest2"
      import "FlowToken"
      import "FungibleToken"

      transaction() {
        prepare(signer: auth(BorrowValue) &Account) {
          let payment <- signer.storage.borrow<auth(FungibleToken.Withdraw) &FlowToken.Vault>(from: /storage/flowTokenVault)!.withdraw(amount: 1.1) as! @FlowToken.Vault

          let gamer = signer.storage.borrow<&TestnetTest2.Gamer>(from: /storage/TestnetTest2Gamer)
              ?? panic("Could not borrow reference to the Owner's Gamer Resource.")
          gamer.insert_coin(payment: <- payment)
        }
        execute {
          log("success")
        }
      }
    `,
    args: (arg, t) => [],
    proposer: authz,
    payer: authz,
    authorizations: [authz],
    limit: 999,
  });
  console.log(txId);
  return txId;
};

これを「Insert ₣1.1 coin」ボタンを押した時に呼び出します。

その前に、ゲームをプレイしているユーザのTestnet用$FLOW(Flow blockchainのネイティブ暗号通貨です)を用意します。https://faucet.flow.com/fund-accountにアクセスし、アドレスを入力することで、Testnet用の100,000FLOWコインをそのアドレスに付与することができます。

アドレスは {gameUser?.addr} とどっかに貼り付けて画面からコピーしましょう(あまり画面にアドレスを出してもプレイヤーは喜ぶと思わないので私は画面にアドレスを出さない予定です)。

image.png


ゲームスタートボタンを押した時のロジックを用意します。

import { insertCoin } from '../../../flow_blockchain/transactions'
import Dialog from './../Dialog.svelte';

async function startBtnClicked() {
  if (!btnClicked) {
    btnClicked = true
    modal.showModal()
    let txId = await insertCoin()
    tx(txId).subscribe((res) => {
      console.log('tx status:', res);
    });
  }
}

<Dialog bind:dialog={modal}>
  <div>You need to accept the transaction on the wallet. Game fee is only 1.1!</div>
</Dialog>

ボタンを押します。

image.png

ここはScript ℹ️の右矢印を押すと本当に1.1FLOW引かれるのかどうかコードで分かります。ですが、エンジニアではない普通の人には到底分かりません。有象無象のスマートコントラクトが世の中に溢れているので、ウォレットに大金を持っていたらそれを引き出すコードかもしれないのです。対策としてはInteraction Templatesにより機関側で見破ることで、ユーザが安心してゲームをできるようになることは間違い無いでしょう。

Approveを押します。(この時エラーになったらtxIdをFlowScan等に貼り付けてエラー内容を確認することをお勧めします)

所持金をリアルタイムに取得する

ゲーム代金を払ったので、残高の推移をリアルタイムに見せる必要があります。

1.5秒おきに実行するsetInterval関数にgetBalanceメソッドを追加します。

setInterval(async () => {
  if (gameUser?.addr) {
    flowBalance = await getBalance(gameUser.addr);
    havingResource = await isRegistered(gameUser.addr);
  }
  currentSituation = await getGamersInfo();
}, 1500);

先ほどのApproveを押した時点でtx(txId).subscribe((res) => {

reserrorMessageもなく、statusStringSEALEDになると支払いが完了したことになります。その時点でゲームをスタートさせます。画面にはSEALEDになったと同時に以下のように表示しました。

image.png

ゲームが勝った時の処理をGraphQLサーバーで処理する

次はゲームが勝った時と負けた時の処理をGraphQLサーバーで実行します。

前々回の記事で以下のように勝った時と負けた時にGraphQL呼び出しを以下のように実装していました。フロントエンドはここを少し修正するだけです。

image.png

querytype’shooting_game_outcome’にします。messageにはonGameLoseの時に’false’を、onGameWonの時には’true’をセットします。playerIdにはgamerIdセットします。(messageの型がStringなので。)

次にGraphQLサーバー側です。

GraphQLサーバーからトランザクションを行うということは秘密鍵を用意してトランザクションを行うことになります。ということは、スマートコントラクトをデプロイしたアカウントによるトランザクションになりますので、Admin Resourceが必要です。(init内で宣言することでinit以外では生成することができないように実装されているリソースのことを俗にAdmin Resourceと言います)

スマートコントラクトにAdminResourceを書いていきます。

1.init内に以下を追記します

self.account.storage.save( <- create Admin(), to: /storage/TestnetTest2Admin)

2.initの枠の上に以下のコードを書きます

 /*
  ** [Resource] Admin (Game Server Processing)
  */
  access(all) resource Admin {
   /*
    ** Save the Gamer's shooting game outcome
    */
    access(all) fun shootingGameOutcome(gamerId: UInt, outcome: Bool) {
      let prizePaid = TestnetTest2.gamersInfo.setCurrentPrize(added: 1, gamerId: gamerId, paid: outcome)
      if (prizePaid > 0) {
        // Pay the prize.
        let reward <- TestnetTest2.account.storage.borrow<auth(FungibleToken.Withdraw) &{FungibleToken.Provider}>(from: /storage/flowTokenVault)!.withdraw(amount: UFix64.fromString(prizePaid.toString().concat(".0"))!) as! @FlowToken.Vault
        TestnetTest2.GamerFlowTokenVault[gamerId]!.borrow()!.deposit(from: <- reward)
      }
    }
  }

型が厳格に判定処理される(UIntからUFixに直接変換できない)ので、UInt整数から文字列に変換して”.0"concatしてUFixに変換しています。(フィールドをUFixで修正するとまた大変なので)

これをデプロイします。

flow project deploy --update --network testnet

を実行します。

image.png

デプロイできました。このようにコードの修正であれば、スマートコントラクトはコードの上書きを受け付けてくれます。(フィールドを変更すると駄目です)

GraphQLサーバのリゾルバを作る

まずは、AWS Lambda Functionのコンソールを開きます。

前々回の記事で作成したoragaESports関数を選択し、Topの画面からDownloadを押してzip形式でダウンロードします。

プロジェクトルートに/aws_lambda/srcというフォルダを新規作成し、そこにダウンロードして解凍したindex.mjsファイルを置きます。(aws_lambdaフォルダの名称は何でも構いません)

image.png

こんな感じです。(flow_blockchainフォルダ赤い(エラーがある)ですが、スマートコントラクトのデプロイはできているのでCadence VSCode Extentionの私の使い方がちょっと間違っているのかもしれません)

このindex.mjsに、トランザクションを実行するコードを書くのですが、@onflow/fclなどのパッケージをインストールする必要があります。

ですので、/aws_lambda/src内にターミナルで移動し

npm init

コマンドを実行してpackage.jsonファイルを作成します。コマンド実行時に聞かれる選択肢は全てデフォルトでいいですが、entry pointだけはindex.jsではなくindex.mjsにしました。

そしてターミナルで次のコマンドを実行します。

npm install @onflow/fcl elliptic sha3

ちょっとこれでいいか怖いので一旦srcフォルダをzip化して、Lambda Functionにアップロードしてリゾルバとして正常に機能するか見てみます。(一応zipしたファイルと分かるようにindex.mjsから返す値を少し変えてます)

srcフォルダをzip化するコマンド(src内から実行):

zip -r ../src.zip .

これによって作成されたsrc.zipをAWS Lambda Functionにアップロードします。

ゲームを実行し、勝つか負けるとGraphQLクエリとそれに対するSubscriptionが呼ばれるのでそれを待ちます。

image.png

おっ、ちゃんと動いてそうですね。AWS Lambdaにアップロードしたzipファイルが正常にGraphQLリゾルバとして認識され、Subscriptionが今修正したreturn値で返されています。

これが便利で、いいんですよ。バックエンドはLambda Functionにアップロードするだけ、フロントエンドはS3にアップロードするだけ、それだけでプロジェクト全体の更新ができるのですから開発速度は爆上がりです。(AWS Lambda Functionコンソールからアップロードする以外の形式は、AWSのリソース(=Cloud Formation)を通じてGraphQLサーバの実装を更新する必要があるため、すごく時間がかかりストレスが溜まります。AWSが仕様を変更しないことを望みます..)

では/aws_lambda/src/index.mjsにGraphQLサーバの処理としてのブロックチェーンに対するトランザクション処理を書いていきます。

バックエンドからトランザクションを実行する処理

index.mjsの書き出しは以下のような感じです。

const fs = require("node:fs");
const { config, sansPrefix, withPrefix, mutate , tx } = require("@onflow/fcl");
const { SHA3 } = require("sha3");

export const handler = async (event) => {
 console.log('Event', JSON.stringify(event, 3));
 const input = event.variables?.input || {};
 let gamerId = input.playerId ? parseInt(input.playerId) : 0;
 let message = input.message ? JSON.parse(input.message) : {};
 let transaction = '';

 if (input.type === 'shooting_game_outcome') {
    transaction = `
      import TestnetTest2 from 0x975b04756864e9ea

      transaction(gamerId: UInt, outcome: Bool) {
        prepare(signer: auth(BorrowValue) &Account) {
          let admin = signer.storage.borrow<&TestnetTest2.Admin>(from: /storage/TestnetTest2Admin)
            ?? panic("Could not borrow reference to the Administrator Resource.")
          admin.shootingGameOutcome(gamerId: gamerId, outcome: outcome)
        }
        execute {
          log("success")
        }
      }
    `;
  }

フロントエンドから来た情報であるevent.variablesからplayerIdとmessageとtypeを取り出し、トランザクションコードをtypeによって決定します。

importするスマートコントラクトのアドレスはflow.jsonに書いてあるものと同じです。

次にfclの設定を行います。

  config({
    "flow.network": "testnet",
    "accessNode.api": "https://rest-testnet.onflow.org",
  });

バックエンドから承認処理も自分たちで行うので設定する内容はこれぐらいになります。

次の関数内で秘密鍵を参照するので、testnet-account.pkeysrcフォルダにコピーしておきます。(AWSのサービスを使って秘密鍵を保存しなくていいのでコストが浮きますな。)

次に承認を行う関数を作ります。

let txId;
try {

  var IT_KEY_ID = 0;
  if (fs.existsSync('/tmp/sequence.txt')) {
   IT_KEY_ID = parseInt(fs.readFileSync('/tmp/sequence.txt', { encoding: 'utf8' }));
  } else {
   IT_KEY_ID = 10;
  }
  IT_KEY_ID = !IT_KEY_ID || IT_KEY_ID >= 10 ? 0 : IT_KEY_ID + 1;
  fs.writeFileSync('/tmp/sequence.txt', IT_KEY_ID.toString());
  console.log('IT_KEY_ID', IT_KEY_ID);

  const EC = require('elliptic').ec;
  const ec = new EC('p256');

  // CHANGE THESE THINGS FOR YOU
  const PRIVATE_KEY = fs.readFileSync('testnet-account.pkey', { encoding: 'utf8' });
  const ADDRESS = '0x975b04756864e9ea';
  const KEY_ID = 0;

  const hash = (message) => {
   const sha = new SHA3(256);
   sha.update(Buffer.from(message, 'hex'));
   return sha.digest();
  };

  const sign = (message) => {
   const key = ec.keyFromPrivate(Buffer.from(PRIVATE_KEY, 'hex'));
   const sig = key.sign(hash(message)); // hashMsgHex -> hash
   const n = 32;
   const r = sig.r.toArrayLike(Buffer, 'be', n);
   const s = sig.s.toArrayLike(Buffer, 'be', n);
   return Buffer.concat([r, s]).toString('hex');
  };

  async function authFunction(account) {
   return {
    ...account,
    tempId: `${ADDRESS}-${KEY_ID}`,
    addr: sansPrefix(ADDRESS),
    keyId: Number(KEY_ID),
    signingFunction: async (signable) => {
     return {
      addr: withPrefix(ADDRESS),
      keyId: Number(KEY_ID),
      signature: sign(signable.message)
     };
    }
   };
  }
  async function authFunctionForProposer(account) {
   return {
    ...account,
    tempId: `${ADDRESS}-${IT_KEY_ID}`,
    addr: sansPrefix(ADDRESS),
    keyId: Number(IT_KEY_ID),
    signingFunction: async (signable) => {
     return {
      addr: withPrefix(ADDRESS),
      keyId: Number(IT_KEY_ID),
      signature: sign(signable.message)
     };
    }
   };
  }

秘密鍵はSecretsManagerなどで管理した方がいいと思います。Lambda Function内でSecretsManagerなどAWSサービスを使用するには準備が必要なので、今回はtestnet-account.pkeyを使用する形にしました。testnet-account.pkeyファイルもsrc内にコピーしておきます。

承認関数3つのうちProposerは鍵をサイクルさせることが仕様になっています(後述のJacob君の動画で詳述されます)。Proposer(提案者)承認関数の中で同じキーIDの鍵を連続で使うとエラーになります。そのため我流ですがkeyIdIT_KEY_IDでサイクルさせるコードを書いてます。テストネットなので、とりあえず10個のキーIDでサイクルさせます。

ここで鍵が10個必要ですので、作ります(キーIDが10あれば良いので中身は全く同じ鍵でも大丈夫です)。

FlowScanでテストネットで作成したアカウントのkeysを見てみます。

https://testnet.flowscan.io/account/975b04756864e9ea?tab=keys

最初はkeyIdが0のものだけ存在します。

ここにkeyIdが1~9のものを追加します。keyの作り方はJacob君の動画を参考にしつつ、こちらのCadenceV1.0対応された方法とを見比べつつ実装します。(ややこしい..) コードは以下になります。

transaction(publicKey: String, numOfKeyToAdd: Int) {
    prepare(signer: auth(AddKey) &Account) {
        let key = PublicKey(
            publicKey: publicKey.decodeHex(),
            signatureAlgorithm: SignatureAlgorithm.ECDSA_secp256k1
        )

        var counter = 0
        while counter < numOfKeyToAdd {
          counter = counter + 1
          signer.keys.add(
            publicKey: key,
            hashAlgorithm: HashAlgorithm.SHA3_256,
            weight: 0.0
          )
        }
    }
}

Proposerなのでweight0でも問題ありません(Jacob君の動画より)。

/flow_blockchain/cadence/transactions/の下にaddKey.cdcというファイルを作成、上記内容をペーストします。

そしてそれをFlow CLIで実行します。(Jacob君の動画の9分目あたりからとSend a Transactionのページを参考に)

flow transactions send ./cadence/transactions/addKey.cdc b37154d511d5ade734a36c29d85f013be7afc3a935c91bab84c9c69bb9ad7f9e984e8ed1f3e39d5c670d6199d242d439e12f8062f368483e9b0b3057fda94a51 9 --network=testnet --signer=testnet-account

ここではkeyId=0の公開鍵から1~9を作っていますが、本当は別々の公開鍵秘密鍵のペアから作らないとあまり意味がありません。ですが、サイクル処理に対応しないと連続でトランザクションをエラーなく実行できないので、とりあえず同一の公開鍵秘密鍵ペアで作成しています。(この方法は鍵サイクルという点では意味がほぼ無しです)

/flow_blockchainディレクトリ下で実行します。

なんかこんなアウトプットがされたら成功しています。

image.png

FlowScanで確認します。

https://testnet.flowscan.io/account/975b04756864e9ea?tab=keys

ちゃんと鍵が10個できてますね。(keyId=0の公開鍵と同じ公開鍵をkeyId=1~9に設定しました。鍵サイクルの観点からすると全く意味がありませんね。)

やっとトランザクション実行部分です!

ブロックチェーンエンジニアって知識の蓄積が大事なんだな、と思いました。

    if (input.type === "shooting_game_outcome") {
      const outcome = message == "true" || message == true;
      txId = await mutate({
        cadence: transaction,
        args: (arg, t) => [arg(gamerId, t.UInt), arg(outcome, t.Bool)],
        proposer: authFunctionForProposer,
        payer: authFunction,
        authorizations: [authFunction],
        limit: 999,
      });
      console.log(`txId: ${txId}`);
      message = `Tx[shooting_game_outcome] is On Going.`;
      tx(txId).subscribe((res) => {
        console.log(res);
      });
    }
    return {
      id: new Date().getTime(),
      type: input.type || "",
      message: `${input.message} , txId: ${txId}`,
      playerId: gamerId,
      createdAt: new Date(),
      updatedAt: new Date(),
    };
  } catch (error) {
    return {
      id: new Date().getTime(),
      type: "E:" + input.type,
      message: error.toString(),
      playerId: gamerId,
      createdAt: new Date(),
      updatedAt: new Date(),
    };
  }
};

最後をこのように締めくくってsrcフォルダをzipします。(src内から実行):

zip -r ../src.zip .

AWS Lambdaコンソールからアップロードしてゲームをします。結果は…

image.png

  const EC = require("elliptic").ec;

などの部分が駄目だ、と言っていますので、requireをimportに直して再トライ

"SyntaxError: Named export 'ec' not found. The requested module 'elliptic' is 
a CommonJS module, which may not support all module.exports as named exports.\n
CommonJS modules can always be imported via the default export, for example 
using:\n\n

import pkg from 'elliptic';\n
const { ec: EC } = pkg;\n"
やっぱりダメか。ellipticのEC contextがimport出来なかったのでrequireにしていました。

このelliptic 開発者、Fedor Indutnyって調べるとすごい人だった。Node.jsのコア開発者で突然Nodeのバージョンが勢いよく上がるきっかけになった人。(NIST) P256とsecp256k1カーブについて端的によくまとまっているサイトはこちら 。ビットコインなどで採用されているsecp256k1はKoblits Curveでありその名前を持つNeal Koblitzはelliptic curveの共同発明者である。P256(secp256r1)は業界標準

対応策は.. CloudWatchログ見るとちゃんと紹介してました。(ブラウザのconsole.logは見切れていたのですが..)

import pkg from "elliptic";
const { ec: EC } = pkg;

に直して再トライ。

"RequestId: 3af050bd-5bea-4e58-b31d-d0c36c0d50b8 Error: Task timed out after 3.00 seconds"

requireは解決したようですね。次は3秒を超えた、というエラーでした。

AWS Lambda FunctionのConfigurationからTimeoutを10秒にしておきます。

再トライ

message: "undefined, txId: undefined"

AWSがGraphQL Direct Lambda Resolverの仕様を変えていたようです。

event.variables?.inputではなくevent.inputから取得するように直して再トライ。(最近CloudWatchLogの反応が早くなりました?GraphQLサーバーの動きをすぐに確認できるのですごく助かります!)

再トライ(Trouble Shooting)

[Error Code: 1008] error caused by: 1 error occurred:
 * transaction verification failed: [Error Code: 1006] invalid proposal key:
 public key 9 on account 975b04756864e9ea does not have a valid signature:
[Error Code: 1008] invalid payload signature: public key 9 on account 
975b04756864e9ea does not have a valid signature: signature is not valid

今度はトランザクションエラーでした。keyが有効でない、というエラーですね。これで一番考えられるのは秘密鍵を正しく取得できていないことが考えられます。key周りのエラーは一般論的なエラーになるので秘密鍵を渡せてなかった時もこんなエラーになったと思います。
(追記: 秘密鍵の先頭に0xがついていてもこのエラーになります。flowのコマンドでアカウントを作成すると先頭に0xがついた状態でpkeyファイルに保存されますので注意が必要です。)

念の為、私が書いたauthorization関数とsign関数が合っているのか調べました(長いので折り畳んでいます)

sign関数はkeyFromPrivateでQiitaを検索するとここにヒットしました。

import * as fcl from '@onflow/fcl';
import { ec as EC } from 'elliptic';
import { SHA3 } from 'sha3';
const ec: EC = new EC('p256');

const produceSignature = (privateKey, msg) => {
  const key = ec.keyFromPrivate(Buffer.from(privateKey, 'hex'));
  const sig = key.sign(this.hashMsg(msg));
  const n = 32;
  const r = sig.r.toArrayLike(Buffer, 'be', n);
  const s = sig.s.toArrayLike(Buffer, 'be', n);
  return Buffer.concat([r, s]).toString('hex');
};

sign関数は合ってそうですね。同じ書き方です。


authorization関数は先ほどの記事の下のDetailed Explanationのリンクの先に、公式が紹介している関数がありました。

const authorizationFunction = async (account) => {
  // authorization function need to return an account
  return {
    ...account, // bunch of defaults in here, we want to overload some of them though
    tempId: `${ADDRESS}-${KEY_ID}`, // tempIds are more of an advanced topic, for 99% of the times where you know the address and keyId you will want it to be a unique string per that address and keyId
    addr: ADDRESS, // the address of the signatory
    keyId: Number(KEY_ID), // this is the keyId for the accounts registered key that will be used to sign, make extra sure this is a number and not a string
    signingFunction: async signable => {
      // Singing functions are passed a signable and need to return a composite signature
      // signable.message is a hex string of what needs to be signed.
      return {
        addr: ADDRESS, // needs to be the same as the account.addr
        keyId: Number(KEY_ID), // needs to be the same as account.keyId, once again make sure its a number and not a string
        signature: sign(signable.message), // this needs to be a hex string of the signature, where signable.message is the hex value that needs to be signed
      }
    }
  }
}

こちらも合ってそうですね。英語が、Flowって最先端のブロックチェーンだから学術論文のような英語で書かれてあるんですよね。DeepLで翻訳してみます。

認証関数の作成方法
幸いなことに認証関数の作成は比較的簡単です

認証関数は最低限以下の2つの機能を備える必要があります

誰が署名するか -- 署名するアカウントとそのアカウントが使用する鍵のkeyIdを特定する
どのように署名するか -- 最初のステップで指定されたアカウントと鍵から署名を取得する方法を知る
認証関数にはアカウントという概念がありますアカウントは取引の署名者候補を表し署名する主体と署名方法の両方を包含します認証機能には空のアカウントが渡されアカウントを返す必要があります認証機能を作成する際の主な作業はこのアカウントに情報を入力し署名したいアカウントが署名できるようにすることです

例えば事前にアカウントとキーIDが分かっている場合署名可能な関数があると仮定しましょう
const ADDRESS = "0xba1132bc08f82fe2"
const KEY_ID = 1 // このテストネット上のアカウントには3つのキーがあります。そのうち、インデックスが1でウェイトが1000のキーを取得したいと考えています。
const sign = msg => { /* ... 指定されたメッセージに対して上記の鍵の署名を生成します ... */ }

私達の認証関数は、主に値を埋める作業になります:

例2:
const authorizationFunction = async (account) => {
  // 認証関数はアカウントを返す必要があります
  return {
    ...account, // ここにデフォルト値がいくつかありますが一部を上書きしたい場合があります
    tempId: `${ADDRESS}-${KEY_ID}`, // tempIdはより高度なトピックですアドレスとKEY_IDが既知の場合そのアドレスとKEY_IDごとに一意の文字列にしたい場合がほとんどです
    addr: ADDRESS, // 署名者のアドレス
    keyId: Number(KEY_ID), // これはアカウントに登録されたキーのキーIDで署名に使用されますこれが数値であり文字列ではないことを確認してください
    signingFunction: async signable => {
      // 署名関数はsignableを受け取り複合署名返す必要があります
      // signable.messageは署名対象のヘックス文字列です
      return {
        addr: ADDRESS, // account.addrと一致する必要があります
        keyId: Number(KEY_ID), // アカウントのkeyIdと一致する必要があります再度数値であり文字列ではないことを確認してください
        signature: sign(signable.message), // これは署名の16進数文字列でsignable.messageは署名対象の16進数値です
      }
    }
  }
}

このように認証関数は合ってそうだったので、一番理由として考えられる秘密鍵だと思うのでconsole.logで変数の中身を出力してあとでCloudWatchLogで見てみます。

const PRIVATE_KEY = fs.readFileSync("testnet-account.pkey", "utf8");
console.log("PRIVATE_KEY:", PRIVATE_KEY);

ちゃんと取得できていました。結論としては、

const ec = new EC(“secp256k1”)の部分がconst ec = new EC(“P256”)になっていることが原因でした。(本当にkeyのcurveが原因でしたね..)

最後のTrouble Shootingはこちらです。

panic("Could not borrow reference to the Administrator Resource.")
  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

これはですね.. スマートコントラクトをupdateしたことが原因です。init関数はデプロイした時にしか呼ばれません。その為init関数でAdmin関数をsaveしたのが無かったことになっています。


以上、これらが大体GraphQL Direct Lambda Resolverの実装をした時に対応が必要な箇所達です。最後は以下のようにコントラクト名の一括変換を行なってデプロイし直せば、このトランザクションは成功します。

image.png

flow project deploy --network testnet

最終的に完成したGraphQL Direct Lambda Resolverのコードはこちらです


とりあえず、先ほど私が実装したスマートコントラクトの実装は合ってそうです。

image.png

(ゲームに負けたことで) Prizeが1から2に増えてますね。

ここまでお読みいただいたら分かると思いますが、Cadenceの実装やBuildは非常に開発者フレンドリーでほとんど一発で成功します。今まででつまづいたのはインフラ処理ばかりだったと思います。

開発が佳境に入ったら

ここまで来ると、開発が佳境に入ってきます。

スマートコントラクトの修正も結構入ってきます。フィールドを追加すると名前をカウントアップ(TestNetTest3 -> TestNetTest4)させて以下の処理を継続的に行うことになります。

# /flow_blockchain の下で
flow project deploy --network testnet

# /aws_lambda/src の下で
zip -r ../src.zip .
# これをAWS LambdaにUpload

# ローカル開発であれば以上で十分ですが、CloudFront経由でテストしたい場合はプロジェクトルートで
npm run build
# 生成される /dist 以下のファイルを全てS3にUpload

GraphQLのReturn値を使用したフロントエンドの実装

まず、一番簡単な負けた時の実装からします。

リアルタイム同時接続のGraphQLを利用して、画面を見ている全員に賞金が上がったよ、とモーダルで通知します。

前々回の記事で実装したGraphQLのSubscription処理:

image.png
(赤いのはtypescriptじゃないかららしい。個人開発なので細かいことは気にせず..)

ここをtxでSEALEDになるまでtransactionをsubscribeします。

  let notificationModal
  let notificationMessage

  const createSub = client
  .graphql({ query: subscriptions.onCreateGameServerProcess })
  .subscribe({
    next: ({ data }) => {
      console.log(data.onCreateGameServerProcess)
      if (data.onCreateGameServerProcess?.type == 'shooting_game_outcome') {
        const res = data.onCreateGameServerProcess?.message.split(' , txId: ')
        const outcome = res[0]
        const txId = res[1]
        if (outcome == 'false') {
          notificationModal.showModal()
          notificationMessage = `Prize money rises! ₣ ${currentPrize} ==> ${currentPrize + 1}`
          tx(txId).subscribe((res) => {
            if (!res.errorMessage && res.statusString == 'SEALED') {
              notificationModal.close()
            }
          });
        }
      }
    },
    error: (error) => console.warn(error)
  });

というようにすることで、トランザクションが行われた後にダイアログで画面を見ている人全員に賞金額が上がったことを伝えます。そしてSEALEDになったらダイアログを閉じます。

image.png
tx.subscribestatusが返るのは少し遅いのでSEALEDになる頃にはcurrentSituationで取得したprizeはすでに6に更新されていました。

ここでGraphQLの大人数リアルタイム同時接続を確認したいのでCloudFront(S3)にアップロードします。

image.png

おぉ、すごいですね。こんなことができるGraphQLを個人で簡単にAWS Lambdaにアップロードするだけでできるなんて。Twitterみたいな動きをみんなに自慢できますね。
(一番右の機種はOSバージョンの問題で下にダイアログが出ています)


ここで前回の記事で上げたこのゲームの機能要件を見てみます。

  1. お金を入金できる。ゲームを開始できる。
  2. ゲームに勝利した時に賞金を受け取れる
  3. ゲームに負けた場合は賞金額に1FLOWを上乗せする
  4. FLOWを持っていない人のために1回だけ無料プレイできるようにTip jar機能を用意する(Tip jarから1FLOWを取り出してそれでゲームする)
  5. Tip jarに寄付(1FLOW)できる
  6. 勝敗の記録をブロックチェーンに残せる

1, 3, 4, 5, 6はできました。(4と5は記載していませんが2日ほどで作成しました)残りは2だけです。

4と5の開発まで来るとスマートコントラクトは200行を超えます。この辺りで大体自分が書いたスマートコントラクトのバグと戦う時期になります。

よくある、と思うのがelse文を書き忘れること。

access(all) fun useTipJarForFreePlay(gamerId: UInt) {
  if let freePlayed = TestnetTest5.gamersInfo.freePlayCount[gamerId] {
       :
  }
}

こういうふうにfreePlayCountというオブジェクトに何回無料プレイしたか保存し、その中の値から判断して、無料プレイを実行する処理を書くとします。

このif let文の中の処理は、チェック処理など含めると結構ボリュームがあるので、これで出来たと思いがちなんですね。でも実際は一度も無料プレイしていない人はこのif let文には入りません。else文を書かないと1行も処理されずにこのメソッド処理がSEALEDされてしまうのです。

何で何の処理もしてないのにSEALEDなんだよ、と憤慨してCadenceのバグを疑う前に自分のコードをちゃんと確認する作業、というのがブロックチェーンエンジニアには発生します。

この書き方は面倒くさい(コード数も増える)ので

let freePlayed = TestnetTest5.gamersInfo.freePlayCount[gamerId]!

みたいな書き方はできないものか、とDiscordで聞いたことがあります。

そしたらコア開発者でリードエンジニアのJoshua Hannanに「Yeah, 毎回if let文を書いてね。」と言われちゃいました。スマートコントラクトで値があるかないかは、リソース(資産)があるかないかなので、クリティカル(資産紛失)になり得るので、面倒くさくてもif letで、ある時とない時の両方のロジックを、コード数が倍になっても書く必要があるのです。

最後のロジック

では最後のゲームに勝利した時に賞金を受け取れるロジックを仕上げます。

ゲームに負けた時と勝った時のスマートコントラクトのメソッドは、以下のようなSetter関数を通っていました(前回の記事の最後の方で書きました)。

access(contract) fun setCurrentPrize(added: UInt, gamerId: UInt, paid: Bool): UInt {
  if (paid == false) {
    self.currentPrize = self.currentPrize + added
    self.unsetTryingPrize(gamerId: gamerId)
    return 0
  } else {
    if let paidPrize = self.tryingPrize[gamerId] {
      if (self.currentPrize - paidPrize > 0) {
        self.currentPrize = self.currentPrize - paidPrize
        self.unsetTryingPrize(gamerId: gamerId)
        if let prizeHistory = self.prizeWinners[gamerId] {
          self.setPrizeWinners(gamerId: gamerId, prize: paidPrize + prizeHistory.prize, nickname: nil)
        } else {
          self.setPrizeWinners(gamerId: gamerId, prize: paidPrize, nickname: nil)
        }
        return paidPrize + added
      }
    }
    panic("Error.")
  }
}

負けた時は上部で、勝った時は下を通るのですが、勝った時にpanic(“Error”)が呼ばれていることがFlowScanでは確認できました(トランザクションが失敗していた)。

ではなぜpanicの行にロジックが到達したのかを調べないといけないのですが、エミュレータと違ってテストネットだとFlowScanで見れるエラー内容だけが頼りなので、私は以下のようにして調査しました。

access(contract) fun setCurrentPrize(added: UInt, gamerId: UInt, paid: Bool): UInt {
  if (paid == false) {
    self.currentPrize = self.currentPrize + added
    self.unsetTryingPrize(gamerId: gamerId)
    return 0
  } else {
    if let paidPrize = self.tryingPrize[gamerId] {
      if (self.currentPrize - paidPrize > 0) {
        self.currentPrize = self.currentPrize - paidPrize
        self.unsetTryingPrize(gamerId: gamerId)
        if let prizeHistory = self.prizeWinners[gamerId] {
          self.setPrizeWinners(gamerId: gamerId, prize: paidPrize + prizeHistory.prize, nickname: nil)
        } else {
          self.setPrizeWinners(gamerId: gamerId, prize: paidPrize, nickname: nil)
        }
        return paidPrize + added
      }
      panic("Error2. Oops, something is not good.".concat(paidPrize.toString()).concat(self.currentPrize.toString()))
    }
    panic("Error. Oops, something is not good.".concat(self.currentPrize.toString()))
  }
}

panicのエラー内容に変数の値を吐き出させました。

FlowScanで確認した結果はこちら:

image.png
FlowScanはちゃんとpanicの発生行数も教えてくれるのですが、変数の値を確認しないと原因はなかなか分からないですよね。

で原因はわかりました。if (self.currentPrize — paidPrize > 0) { の部分を

if (self.currentPrize — paidPrize >= 0) { にしていなかったからpanicの行まで行ってしまったんですね。

というデバッグ方法のご紹介でした。

勝った時のロジックは先ほどの修正でちゃんと動くようになりました(お金が実際に動く(ウォレットに振り込まれる)処理がこんなにすんなり実装できるのか、とあっけなく動作したらすごく無性に嬉しくなります。ぜひ皆さんも実装して体験してみてください)。

ゲームをしている間に他のプレイヤーが賞金を取った時と他のプレイヤーが賞金を増やした時の対応

他のプレイヤーが賞金を増やした時は、その後でプレイする人がその賞金を獲得するので、考える必要はなかったです。

問題はゲームをしている間に他のプレイヤーが賞金を取った時ですね。

これは前々回の記事で書いたように「おっと誰かが賞金をかっさらったようだ。あなたの賞金は₣1にリセットされてしまったよ。」と伝えてあげなければいけません。

ゲームをプレイ中に他のプレイヤーが賞金をかっさらったら、以下のように表示することにします。

image.png

これはゲームに負けた時と同じようにGraphQLのSubscription内で実装します(他の人が勝った時もこのQueryのSubscriptionが呼ばれるからです)。

Subscriptionで受け取る値にはPlayerIDがあるので、それを比較して他人と自分の両方のパターンに分け、ゲーム中ならば邪魔にならないように上記画像のような画面で、ゲーム中ではないならダイアログで出して数秒後に閉じる実装にします。

こんな感じにしました。


これで実装は以上です!開発期間は(フルタイムで仕事しながらで)3週間でした。こんな短期間で開発できて、世界中からゲームをしてもらってお金を稼げるなんてすごい副業じゃないですか?Cadenceなら実装がこんなに簡単なんですよ!

ぜひ皆さんもゲームを作ってFlow blockchain eSportsを盛り上げましょう。盛り上がれば盛り上がるほどあなたの作ったゲームが世界中でバズってお金を稼げるようになるはずです。

Mainnetにリリース

リリースしちゃいましょう。どうせ、告知しなければ誰も怪しんでゲームしませんからさっさとスマートコントラクトをMainnetにあげてしまいます。

私がMainnetにリリースするのはCadenceのVersionが1.0に変わる前以来ですから、私もうまくいくかドキドキです。

  1. スマートコントラクトをコピーし、メインネット用とテストネット用(OragaESportsForTestnet.cdc)に分ける
  2. flow.jsonファイルの上部のcontractsdeploymentsを以下のようにする
  "contracts": {
    "TestnetTest5": {
      "source": "cadence/contracts/OragaESportsForTestnet.cdc",
      "aliases": {
        "testing": "0000000000000007"
      }
    },
    "OragaESports": {
      "source": "cadence/contracts/OragaESports.cdc",
      "aliases": {
        "testing": "0000000000000007"
      }
    }
  },
  "deployments": {
    "emulator": {
      "emulator-account": ["TestnetTest5"]
    },
    "testnet": {
      "testnet-account": ["TestnetTest5"]
    },
    "mainnet": {
      "mainnet-account": ["OragaESports"]
    }
  },

3. /aws_lambdaフォルダの下にtestnetフォルダとmainnetフォルダを作成し、その下に/aws_lambda/srcフォルダをそれぞれ配置する

4. /flow_blockchainフォルダの下にtestnetフォルダとmainnetフォルダを作成し、その下にscripts.jstransactions.jsファイルをそれぞれ配置する。

5. /aws_lambda/mainnetディレクトリと/flow_blockchain/mainnetディレクトリ配下のファイル内とOragaESports.cdcファイル内のコントラクト名をOragaESportsに一括変換、testnet-account.pkeymainnet-account.pkeyにリネームする。

6. mainnet用の公開鍵秘密鍵を生成する。

flow keys generate --sig-algo "ECDSA_secp256k1"

mainnet-account.pkeyに上記コマンドで生成されたPrivate Keyをペーストする。mainnet-account.pkey.gitignoreに追加する。git statusを実行してmainnet-account.pkeyが出てこないことを確認する。(秘密鍵のファイル管理は本当に危ないので非推奨です。AWSかGCPのサービスを使って下さい)。

7. 次はメインネットのアカウントを作成します。Jacob君のビデオでは約2年前にflow-climainnet用にアカウント作成コマンドが作成されているようなのでそれを実行します。(私は公式ドキュメントよりJacob君のビデオの方を信頼しています。公式も無闇に変えられないのです。)

flow accounts create --network=mainnet

image.png

account nameを入れろ? って何だそれは。2年前はなかったぞ?

ここに以下のような表記がありました。

image.png

現在はaccount nameを入力しないとアカウントを作れないようですね。

何て入れようかな。oragaでいいか。

image.png

うん? public key? ということはflow.jsonは..

image.png

あっ、oraga.pkeyというファイルに秘密鍵が生成されている。

鍵の種類もECDSA_secp256k1というビットコインやイーサリアムで使われているカーブではなくなっているようなので、再トライします。(その前にmainnet-account.pkeyを削除しておきます)

flow accounts create --network=mainnet --sig-algo=ECDSA_secp256k1
Enter an account name: mainnet-account█

なるほど、確かに便利です。Flowのメインネットのアドレスとそれに紐づく公開鍵、秘密鍵を生成してくれて、flow.jsonにも自動でセットしてくれました。

(追記:注意! --sig-algo--keyフラグがないと無視されます。そしてECDSA_P256カーブでkeyが作成されてしまいます。ですのでECDSA_secp256k1カーブでkeyを作成する場合はやっぱり6の処理で公開鍵/秘密鍵を作って --keyフラグで公開鍵を渡す必要があります。)

8. 残りはコントラクトのデプロイです。

flow project deploy --network mainnet

これでflow.jsonの設定値を参考にデプロイしてくれるはず。どきどき。。

image.png

ストレージリミットを超えたからTokenを持たせろ、と言っている...

トークンを送付しますか…

b576a3926d239682 に対してFlowトークンを送付します。

どれぐらい必要なのかわからないのでとりあえず1FLOWのトークンをBloctoを使って送付します。


再度実行。

flow project deploy --network mainnet

めっちゃ時間かかる。。

image.png

あっできた。この時点でFlowScanのContractsタブを見てみるとOragaESportsコントラクトが見えます。

image.png

https://www.flowscan.io/contract/A.b576a3926d239682.OragaESports?tab=deploymentsの画面でデプロイしたスマートコントラクトのコードも見ることができました。(FlowScanの仕様変更でリンク切れになるかもしれませんが)

9. では次は今デプロイしたこのコントラクトにアクセスします。

  fcl.config({
    'flow.network': 'testnet',
    'accessNode.api': 'https://rest-testnet.onflow.org',
    'discovery.wallet': 'https://wallet-v2-dev.blocto.app/-/flow/authn',
    'app.detail.title': 'Oraga eSports',
    'app.detail.icon': 'https://oraga-esports.com/assets/MMO%20RPG.png',
  }).load({ flowJSON });

この部分をBloctoのサイトを参考に以下のようにします。

fcl.config({
    'flow.network': 'mainnet',
    'accessNode.api': 'https://rest-mainnet.onflow.org',
    'discovery.wallet': 'https://wallet-v2.blocto.app/-/flow/authn',
    'app.detail.title': 'Oraga eSports',
    'app.detail.icon': 'https://oraga-esports.com/assets/MMO%20RPG.png',
  }).load({ flowJSON });

とします。以上かな?

10. ゲーム画面にアクセス。(10の内容は私の凡ミスによるエラーに対するトラブルシューティングが書かれているだけなので折り畳んでいます)

image.png

エラーになりますね。。トラブルシューティングに入ります。

これはコントラクトが参照できてない内容のエラーですね。どこか他にtestnetに対してconfig処理しているところがないか探します。
あっ。。。ブログの方だけ変えてコードの方はtestnetのままだった。。

image.png

今度は違うエラー… OragaESportsコントラクトを参照できるようにはなったけど、違うエラーが出ています。

このエラーが発生しているのは以下のコードの場所なのでここを調べます。

export const getGamersInfo = async function () {
  const result = await query({
    cadence: `
    import "OragaESports"

    access(all) fun main(): OragaESports.GamersInfo {
      return OragaESports.getGamersInfo()
    }
    `,
  });
  return result;
};

import “OragaESports”の部分は }).load({ flowJSON }); の部分の行によって

import OragaESports from 0xb576a3926d239682 に変換されています。

hostname=https://rest-testnet.onflow.org
      path=/v1/scripts?block_height=final
      method=POST
      requestBody={"script":"CiAgICBpbXBvcnQgT3JhZ2FFU3BvcnRzIGZyb20gMHhiNTc2YTM5MjZkMjM5NjgyCgogICAgYWNjZXNzKGFsbCkgZnVuIG1haW4oKTogT3JhZ2FFU3BvcnRzLkdhbWVyc0luZm8gewogICAgICByZXR1cm4gT3JhZ2FFU3BvcnRzLmdldEdhbWVyc0luZm8oKQogICAgfQogICAg","arguments":[]}
      responseBody={
 "code": 400,
 "message": "Invalid Flow argument: failed to execute the script on the execution node execution-001.devnet52.nodes.onflow.org:3569: rpc error: code = InvalidArgument desc = failed to execute script: [Error Code: 1101] failed to execute script at block (790b1d90d17156ac582acb57081531195d53b911552cac7ca7794eda602cf8d7): [Error Code: 1101] error caused by: 1 error occurred:\n\t* [Error Code: 1101] cadence runtime error: Execution failed:\nerror: cannot find declaration `OragaESports` in `b576a3926d239682.OragaESports`\n --\u003e ec03acd882738e97425e1ced2090a99fd1cd75476a8d4373eb32963a33b34104:2:11\n  |\n2 |     import OragaESports from 0xb576a3926d239682\n  |            ^^^^^^^^^^^^ available exported declarations are:\n\n\nWas this error unhelpful?\nConsider suggesting an improvement here: https://github.com/onflow/cadence/issues.\n\n\n"

ブラウザのconsoleログに出ていたエラーをペーストしてきました。

hostnamehttps://rest-testnet.onflow.orgになっていますね。。なんでtestnet
あっ。。違うファイルでもconfig処理していた。(すみません、fcl.configconfigと違う書き方をしていたので気づきませんでした

というわけで10は全て私が悪いです。ちゃんと画面には現在の賞金額とTip Jarの残高がデプロイしたMainnetの情報で表示されていました。

11. SignInボタンでBloctoにサインインします。(ここも私の凡ミスですが)

すると画面がうっすら暗くなっただけ。左上に文字が小さく見えるのでF12ツールを使ってコピーしてきました。

{
  "error_code":"authentication_id_required",
  "message":"Missing 'authenticationId' query param"
}

何だろ、Bloctoと関係あるのかな。authenticationId…

何かBloctoに登録しないといけなかったっけ。あっ、またやってしまった..

https://wallet-v2.blocto.app/-/flow/authn

でした、discovery.walletに設定しないといけないのは。/-/って何だ、と思ってBloctoのサイトのback channelの方をセットしてしまっていました(よく確認しましょう)。9.のところは直しておきます。

image.png

ログインできました。残高も表示されました。

12. 最後はS3に対するアップロードとLambdaに対するアップロードのみです。

npm run build
# /build以下をS3にアップロード

cd /aws_lambda/mainnet/src
zip -r ../src.zip .
# /aws_lambda/mainnet/src.zip をLambdaにアップロード

Lambdaにアップロードした後はcodeタブでコードを確認できるので軽く更新されているか確認してみます。

13. https://oraga-esports.com/のURLで確認します

image.png

何だ何だ、またエラーか。。

このpanicを呼び出しているところを探します。

export const getBalance = async function (address) {
  const result = await query({
    cadence: `
    import "FlowToken"
    import "FungibleToken"

    access(all) fun main(address: Address): UFix64 {
      let vaultRef = getAccount(address).capabilities
          .borrow<&FlowToken.Vault>(/public/flowTokenBalance)
        ?? panic("Something wrong happened.")
      return vaultRef.balance
    }
    `,
    args: (arg, t) => [arg(address, t.Address)],
  });
  return result;
};

ここですね。ウォレット残高取得部分か.. ローカルでは出来てたから..

結果はキャッシュでした。CloudFrontでInvalidationするだけではChromeはキャッシュを表示し続けるのですね。(Chromeのキャッシュクリアで解決します)

次はスマートフォンです。なぜかSigninするポップアップが出ない。

signoutボタンが出ているので以前のログイン情報が何故か残っていたようです。signoutボタンを押すとポップアップが出ました。

Let’s play my eSports!

image.png

名前を登録してFLOW Tokenを別のアドレスから送金しておきプレイ開始します。

image.png

負けた。。こうやって1FLOWでもコインを失うのは悲しいですね。取り返したくなる。。(Prizeが増えてないのでLambdaファイルにトランザクションが実行できないバグがあるようです。後で直しておきます)

先ほど作成したMainnetのスマートコントラクトをデプロイしたアドレスには、プレイ料金が0.1FLOWずつ蓄積されます。それを他の自分のウォレットアカウントに送金する方法はこちらで紹介されているコードとJacob君のビデオをご覧ください。

以上です〜。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?