仮想通貨で決済したらデジタルアートがもらえるシステム作ってみた

# はじめに
突然ですが皆さん、仮想通貨決済を利用したことはありますか？
こと日本においてもまだまだ導入事例は少なく、一度も体験したことのない方も多いのではないでしょうか。
下記サイトによると仮想通貨Symbolを利用できるサービス数は日本全国で39サービスだそうです（2023/12/15時点）。~~とんかつ美味しそう。~~

https://everyday-symbol.studio.site/


良くも悪くもエンタメの域を超えない昨今の仮想通貨決済シーンですが「そんな今だからこそチャレンジできることもあるのでは？」と、2023年11月より以下のようなイベントを試験的に実施しています。

https://x.com/ry0_cha_ng/status/1722608035150065853?s=20

今回はそんな今後の仮想通貨決済シーンを彩る（かもしれない）トークン自動配布システムを作成してみましたので簡単にご紹介していきます。


# 参考


https://qiita.com/nem_takanobu/items/4f50e5740318d92d7dcb


https://qiita.com/nem_takanobu/items/dd297957b8e3588968d4


https://github.com/xembook/quick_learning_symbol

特に以下の章
* [4.トランザクション](https://github.com/xembook/quick_learning_symbol/blob/main/04_transaction.md)
* [5.モザイク](https://github.com/xembook/quick_learning_symbol/blob/main/05_mosaic.md)
* [10.監視](https://github.com/xembook/quick_learning_symbol/blob/main/10_observer.md)




# 構成図
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2239577/d1f9beb4-3dc8-0f57-eb16-954a0bc2f9a5.png)

* 送付用Symbolアカウントの秘密鍵をサーバ内で管理したくなかったため、Systems Managerを利用しAPI実行時のみ取得する構成としています。
* EC2インスタンスはブロックチェーン監視およびAPI実行役なので最小限のスペックとし、セキュリティグループによってインバウンド通信をすべて遮断しています。またNFT送付用APIも特定環境下でのみ実行可能としています。



# EC2実行スクリプト
```observer.js
const sym = require("./node_modules/symbol-sdk");
const WebSocket = require('ws');
const axios = require('axios');
const NODES = [
  'https://sym-main-03.opening-line.jp:3001',
  // ...
];

// ノードリスト内からアクティブなノードを取得する
const connectNode = async (nodeList) => {
  const node = nodeList[Math.floor(Math.random() * nodeList.length)];
  try {
    const response = await axios.get(node + '/node/health', { timeout: 1000 });
    if (response.data.status.apiNode == 'up' && response.data.status.db == 'up') {
      return node;
    } else {
      return '';
    }
  } catch (e) {
    return '';
  }
};

const createRepo = (nodes) => {
  return connectNode(nodes).then(async function onFulfilled(node) {
    const repo = new sym.RepositoryFactoryHttp(node);
    try {
      epochAdjustment = await repo.getEpochAdjustment().toPromise();
    } catch(error) {
      return await createRepo(nodes);
    };
    return await repo;
  });
}

const listenerKeepOpening = async (nodes) => {
  const repo = await createRepo(nodes);
  let wsEndpoint = repo.url.replace('http', 'ws') + "/ws";
  const nsRepo = repo.createNamespaceRepository();
  const lner = new sym.Listener(wsEndpoint, nsRepo, WebSocket);
  try {
    await lner.open();
    lner.newBlock().subscribe(block => {
      console.log(block);
    });
    // 送信アドレスが店舗アドレスでない場合APIを実行する
    lner.confirmed(sym.Address.createFromRawAddress('店舗側アドレス')).subscribe(tx => {
      if (tx.signer.address.address !== '店舗側アドレス') {
        axios.post('APIエンドポイントURL', {
          customerAddress: tx.signer.address.address,
          storeAddress: '店舗側アドレス',
          mosaics: tx.mosaics,
          txHash: tx.transactionInfo?.hash
        }).then(response => {
          console.log(response);
        });
      }
    });
  } catch(e) {
    console.log(e);
  }

  lner.webSocket.onclose = async function(){
    console.log("listener onclose");
    return await listenerKeepOpening(nodes);
  }
  return lner;
}

listenerKeepOpening(NODES);
```
実行結果やエラーをお好みのチャットツールに通知する仕組みを加えることで快適な運用に繋がります。上記に記載はしていませんが本システムでは別途APIを用意し以下のようにDiscordへ通知しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2239577/fc5acf64-f04e-e714-cd7f-b177d19b2a06.png)




# Lambda実行スクリプト
```sendNft.ts
import * as ssm from '@aws-sdk/client-ssm';
import {
  Account,
  Address,
  Deadline,
  Mosaic,
  MosaicId,
  Order,
  PlainMessage,
  RepositoryFactoryHttp,
  Transaction,
  TransactionGroup,
  TransactionStatus,
  TransactionType,
  TransferTransaction,
  UInt64,
} from 'symbol-sdk';
import { firstValueFrom } from 'rxjs';
import axios from 'axios';
import { WebSocket } from 'ws';
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';

const client = new ssm.SSMClient({
  region: 'ap-northeast-1',
});

type sendNftProps = {
  // 顧客の支払いアドレス
  customerAddress: string;
  // 店舗の受取アドレス
  storeAddress: string;
  // 送信されたモザイク情報
  mosaics: Mosaic[];
  // 送信トランザクションのハッシュ値
  txHash: string;
};

type responseType = {
  statusCode: number;
  body: string;
};

export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
  try {
    if (event.body == null) throw new Error('Params not found.');
    const props: sendNftProps = JSON.parse(event.body);

    // ノードリスト内からアクティブなノードを取得する
    const connectNode = async (nodeList: string[]) => {
      const node = nodeList[Math.floor(Math.random() * nodeList.length)];
      try {
        const response = await axios.get(node + '/node/health', { timeout: 1000 });
        if (response.data.status.apiNode == 'up' && response.data.status.db == 'up') {
          return node;
        } else {
          return '';
        }
      } catch (e) {
        return '';
      }
    };

    // モザイクIDをランダムで1つピックアップする
    const selectMosaicId = (mosaics: Mosaic[]): string => {
      if (mosaics.length === 0) return '';
      
      // symbol.xymを抽選対象から外す
      const filteredMosaics = mosaics.filter(id => id.id.toHex() !== '6BED913FA20223F8');
      const randomIndex = Math.floor(Math.random() * filteredMosaics.length);
      const randomElement = filteredMosaics[randomIndex];
      const randomMosaicId = randomElement.id;
      return randomMosaicId.toHex();
    };

    // 2つの日付が同じかどうか確認する
    const areSameDate = (date1: Date, date2: Date, txHash: string): boolean => {
      // 履歴に含まれる今回の支払い分は判定から除外する
      if (txHash === props.txHash) return false;

      const tokyoTimezoneOffset = 9 * 60;
      const date1Tokyo = new Date(date1.getTime() + (tokyoTimezoneOffset * 60 * 1000));
      const yearA = date1Tokyo.getUTCFullYear();
      const monthA = date1Tokyo.getUTCMonth();
      const dayA = date1Tokyo.getUTCDate();
      const date2Tokyo = new Date(date2.getTime() + (tokyoTimezoneOffset * 60 * 1000));
      const yearB = date2Tokyo.getUTCFullYear();
      const monthB = date2Tokyo.getUTCMonth();
      const dayB = date2Tokyo.getUTCDate();
      return yearA === yearB && monthA === monthB && dayA === dayB;
    }

    // 同日内に同じcustomerAddressから支払いがある場合はNFTを送信しない
    const checkPaymentHistory = async (transactions: Transaction[]): Promise<boolean> => {
      let matchedHistory = [];
      for (const tx of transactions) {
        const now = new Date();
        const historyDate = tx.transactionInfo?.timestamp?.compact() ? (epochAdjustment * 1000) + tx.transactionInfo?.timestamp?.compact() : 0;
        const paymentDate = new Date(historyDate);
        const txHash = tx.transactionInfo?.hash ? tx.transactionInfo.hash : '0';
        if (
          // 顧客アドレスからの送信である
          props.customerAddress === tx.signer?.address.plain() &&
          // nowとpaymentDateが同じ日付である
          areSameDate(now, paymentDate, txHash) &&
          // モザイクが送信されている
          tx.mosaics?.length > 0 &&
          // symbol.xymが送信されている
          (
            tx.mosaics?.some(mosaic => mosaic.id.toHex() === '6BED913FA20223F8') ||
            tx.mosaics?.some(mosaic => mosaic.id.toHex() === 'E74B99BA41F4AFEE')
          )
        ) {
          // 同日支払いありとして送信しない
          matchedHistory.push(1);
        }
      };
      return matchedHistory.length > 0 ? true : false;
    }

    const networkType = 104;
    const epochAdjustment = 1615853185;
    const generationHash = '57F7DA205008026C776CB6AED843393F04CD458E0AA2D9F1D5F31A402072B2D6';
    const minFeeMultiplier = 100;
    const nodeList = [
      'https://sym-main-03.opening-line.jp:3001',
      // ...
    ];

    // ParameterStoreから秘密鍵を取得
    const ssmKey = 'パラメータ名';
    const input = {
      Name: ssmKey,
      WithDecryption: true,
    };
    const command = new ssm.GetParameterCommand(input);
    const response = await client.send(command);
    if (!response.Parameter?.Value) throw new Error('SSM Params not found.');
    const privatekey = response.Parameter.Value;

    // パラメータ定義
    const admin = Account.createFromPrivateKey(privatekey, networkType);
    const recipientAddress = Address.createFromRawAddress(props.customerAddress);
    const node = await connectNode(nodeList.split(','));
    const repo = new RepositoryFactoryHttp(node, {
      websocketUrl: node.replace('http', 'ws') + '/ws',
      websocketInjected: WebSocket,
    });
    const txRepo = repo.createTransactionRepository();
    const tsRepo = repo.createTransactionStatusRepository();
    const accountRepo = repo.createAccountRepository();
    const listener = repo.createListener();

    // storeAddressのその日の履歴を取得する
    const history = await firstValueFrom(txRepo.search({
      recipientAddress: Address.createFromRawAddress(props.storeAddress),
      group: TransactionGroup.Confirmed,
      type: [TransactionType.TRANSFER],
      order: Order.Desc,
    }));

    const didPayment = await checkPaymentHistory(history.data);
    if (didPayment) throw new Error('本日既に支払い済みのアカウントから送信がありました。\nhttps://symbol.fyi/accounts/' + props.customerAddress);

    // 限定モザイクID
    const limitedMosaicIds = [
      '0123456789ABCDEF',
      // ...
    ];

    // 管理アカウントが所有しているモザイク情報を取得する
    const accountInfo = await firstValueFrom(accountRepo.getAccountInfo(admin.address));
    const mosaics = accountInfo?.mosaics;
    let filteredIds: string[] = [];
    let filteredMosaics: Mosaic[] = [];
    let selectedMosaicId = '';
    let selectedMessage = ''
    switch (props.storeAddress) {
      case '店舗側アドレス':
        // 他店舗限定のNFTを除外した後抽選を実施
        filteredIds = limitedMosaicIds.filter(id => id !== '対象モザイクID');
        filteredMosaics = mosaics.filter(mosaic => !filteredIds.includes(mosaic.id.toHex()));
        selectedMosaicId = selectMosaicId(filteredMosaics);
        selectedMessage = 'ご来店いただき誠にありがとうございました！';
        break;
    // 以下同様に対象アドレス分追加
    }

    // 転送トランザクション作成
    const transferTx = TransferTransaction.create(
      Deadline.create(epochAdjustment),
      recipientAddress,
      [new Mosaic(new MosaicId(selectedMosaicId), UInt64.fromUint(1))],
      PlainMessage.create(selectedMessage),
      networkType
    ).setMaxFee(minFeeMultiplier);

    // 署名およびアナウンス
    const signedTx = admin.sign(transferTx, generationHash);
    await listener.open();
    await firstValueFrom(txRepo.announce(signedTx));

    // トランザクション監視
    const res = (): Promise<responseType> => {
      return new Promise((resolve) => {
        // 未承認トランザクションの検知
        listener
          .unconfirmedAdded(recipientAddress, signedTx.hash)
          .subscribe(async (unconfirmedTx) => {
            const response: TransactionStatus = await firstValueFrom(
              tsRepo.getTransactionStatus(signedTx.hash)
            );
            listener.close();
            clearTimeout(timerId);
            resolve({
              statusCode: 200,
              body: JSON.stringify({ 
                txHash: response.hash,
                recipientAddress,
                selectedMosaicId,
                message: selectedMessage
              }),
            });
          });

        //未承認トランザクションの検知ができなかった時の処理
        const timerId = setTimeout(async function () {
          const response: TransactionStatus = await firstValueFrom(
            tsRepo.getTransactionStatus(signedTx.hash)
          );
          //監視前に未承認TXがノードに認識されてしまった場合
          if (response.code === 'Success') {
            listener.close();
            resolve({
              statusCode: 200,
              body: JSON.stringify({ 
                txHash: response.hash,
                recipientAddress,
                selectedMosaicId,
                message: selectedMessage
              }),
            });
          }
          //トランザクションでエラーが発生した場合の処理
          else {
            listener.close();
            resolve({
              statusCode: 201,
              body: JSON.stringify({ message: response.code }),
            });
          }
        }, 1000); //タイマーを1秒に設定
      });
    };

    const r = await res();
    return {
      statusCode: r.statusCode,
      headers: { 'Content-Type': 'application/json' },
      body: r.body,
    };
  } catch (e: unknown) {
    if (e instanceof Error) {
      console.log(e);
      return {
        statusCode: 201,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message: e.message }),
      };
    } else {
      console.log(e);
      return {
        statusCode: 201,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message: e }),
      };
    }
  }
};
```
その他「悪質なアカウントは対象外とする」「一定数量未満の支払いでは送付しない」などの制御を加えることでより目的に沿ったシステムにカスタマイズできそうです。

# まとめ
今回はSymbolブロックチェーンを利用した簡単なトークン自動配布システムについてご紹介しました。インターネット上に開発資料や参考記事が充実していたので存外サクサクと組み上げることができました。

小規模とは言えどリアルな社会でブロックチェーンを活用した取り組みに挑戦でき、企画/開発/運用など様々な面でたくさんの学びを得られています。企画参加を快諾してくださったサービス提供者の皆様、アート作品を創り上げてくださったクリエイターの皆様、様々な面から支援してくださった皆様、本当に感謝しております。ありがとうございます。
来年は何を作ろうかな。