ActiveTK's Note

PHPでサイトにBitcoin決済を導入する方法


作成日時 2023/08/21 21:38
最終更新 2023/08/21 23:41


  • まず初めに
  • 決済処理の実装
  • バックエンドの実装
  • クライアントの実装

  • まず初めに

    PHPでBitcoinを扱えるライブラリは幾つかありますが、どれも最終更新が5年以上前であり、使い物になりません。
    本記事では、PHPでサイトにBitcoin決済を導入する方法をご紹介させて頂きます。


    決済処理の実装

    肝心の決済処理は、予算的にBitcoin Coreノードを立てることができないので、外部のAPIに依存します。

    運用するサイトが大規模な場合には、外部のAPIではなく専用のVPSを契約してCoreノードを立てることをお勧めします。

    <?php
    
    // 支払いが行われたか確認する関数 by ActiveTK.
    // $BTCAddr : 支払先のBitcoinアドレス
    // $Amount : 支払うべき金額
    function Is_Paid_Success($BTCAddr, $Amount) {
    
      // Bitcoinアドレスに関連するトランザクションを全て取得
      $Transactions = json_decode(file_get_contents("https://blockstream.info/api/address/" . $BTCAddr . "/txs"), true);
    
      // それぞれのトランザクションごとに処理
      foreach($Transactions as $TxInfo)
    
        // トランザクションが承認されている場合
        if ($TxInfo["status"]["confirmed"] == true)
    
          // トランザクションの出力(送金先)をそれぞれ確認
          foreach($TxInfo["vout"] as $Output)
    
            // もしも送金先が引数と等しく、かつ支払うべき金額を超えている場合
            if ($Output["scriptpubkey_address"] == $BTCAddr && $Amount * 10**8 <= $Output["value"] )
              return true;
    
      // それ以外の場合
      return false;
    
    }

    また、Bitcoin受け取り用のアドレスは、以下のように生成できます。

    こちらも、予算に余裕がある場合にはできるだけ外部に依存せず、ローカルで完結するべきです。

    <?php
    
      $NewCoin = preg_split("/\r\n|\r|\n/", file_get_contents("https://bitcoin.activetk.jp/gen"));
      $PubAddress = trim(explode(": ", $NewCoin[0])[1]);
      $PrivAddress = trim(explode(": ", $NewCoin[2])[1]);
    
      echo "公開鍵: {$PubAddress}, 秘密鍵(WIF): {$PrivAddress}";
    
    

    バックエンドの実装

    上記の関数を利用して、以下のようにバックエンド処理を実装します。

    <?php
    
        // 販売金額
        $value = 0.0005;
    
        // 保存用のデータベース
        define( "DSN", "mysql:dbname=データベースの名前;host=127.0.0.1;charset=UTF8" );
        define( "DB_USER", "root" );
        define( "DB_PASS", "password" );
        $dbh = new PDO( DSN, DB_USER, DB_PASS );
    
        /*!
         *
         * 事前に以下のようなテーブルを作成して下さい
         *
    
    CREATE TABLE `PaymentsData` (
      `PaymentID` varchar(40) DEFAULT '',
      `CreateTime` varchar(16) DEFAULT '',
      `UserIPaddr` varchar(400) DEFAULT '',
      `UserAgent` varchar(400) DEFAULT '',
      `AmountBTC` varchar(20) DEFAULT '',
      `PaymentAddrPub` varchar(200) DEFAULT '',
      `PaymentAddrWIF` varchar(200) DEFAULT '',
      `PaymentStatus` varchar(200) DEFAULT '',
      `ProductID` varchar(200) DEFAULT '',
      `DownloadCount` varchar(20) DEFAULT ''
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8
    
        */
    
        // $_GET["status"] にはユーザーの購入用IDが入る
        if (isset($_GET["status"])) {
          header("Content-Type: application/json;charset=UTF-8");
    
          // まだ割り当てられていない(新規発行)の場合の処理
          if (empty($_GET["status"]) || !is_string($_GET["status"])) {
    
            // 購入用IDをランダムに割り当て
            $NewPaymentID = substr(base_convert(sha1(md5(uniqid()).md5(microtime())), 16, 36), 0, 10);
    
            // 受け取り用のBitcoinアドレスを生成
            $NewCoin = preg_split("/\r\n|\r|\n/", file_get_contents("https://bitcoin.activetk.jp/gen"));
            $PubAddress = trim(explode(": ", $NewCoin[0])[1]);
            $PrivAddress = trim(explode(": ", $NewCoin[2])[1]);
    
            $UploaderIPaddr = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : "";
            $UploaderUA = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : "";
    
            // データベースに記録
            try {
              $stmt = $dbh->prepare(
               "insert into PaymentsData(
                  PaymentID, CreateTime, UserIPaddr, UserAgent, AmountBTC, PaymentAddrPub, PaymentAddrWIF, PaymentStatus, ProductID, DownloadCount
                )
                value(
                  ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
                 )"
              );
              $stmt->execute( [
                $NewPaymentID,
                time(),
                $UploaderIPaddr,
                $UploaderUA,
                $value,
                $PubAddress,
                $PrivAddress,
                "Waiting",
                $row["UniqueID"],
                "0"
              ] );
            } catch (\Throwable $e) { }
    
            // 発行した購入用IDを表示
            // newsessidはID、addressは支払い用のアドレス、amountは金額、timeは経過時間
            echo json_encode(array("newsessid"=>$NewPaymentID, "address"=>$PubAddress, "amount"=>$value, "time"=>0));
    
          }
          // 既に発行している場合の処理
          else
          {
    
            // データベースから現在の状態を取得
            try {
              $stmt = $dbh->prepare('select * from PaymentsData where PaymentID = ? limit 1;');
              $stmt->execute( [$_GET["status"]] );
              $pay = $stmt->fetch( PDO::FETCH_ASSOC );
            } catch ( \Throwable $e ) {
              die(json_encode(array("error"=>"決済処理中にエラーが発生しました(エラーコード1)。")));
            }
    
            // 存在しない購入用IDが指定された場合
            if (!isset($pay["PaymentID"]))
              die(json_encode(array("error"=>"決済処理中にエラーが発生しました(エラーコード2)。")));
    
            // 既に購入が完了している場合
            else if ($pay["PaymentStatus"] == "Done" || Is_Paid_Success($pay["PaymentAddrPub"], $pay["AmountBTC"])) {
              try {
                // 購入完了を記録して、次回からトランザクションを確認する手間を無くす
                if ($pay["PaymentStatus"] != "Done") {
    
                  $stmt = $dbh->prepare("update PaymentsData set PaymentStatus = ? where PaymentID = ?;");
                  $stmt->execute([
                    "Done",
                    $pay["PaymentID"]
                  ]);
    
                }
              } catch (\Throwable $e) { }
    
              exit(json_encode(array("done"=>$pay["PaymentID"], "address"=>$pay["PaymentAddrPub"], "amount"=>getDec( $pay["AmountBTC"]))));
            }
            exit(json_encode(array("address"=>$pay["PaymentAddrPub"], "amount"=>getDec( $pay["AmountBTC"]), "time"=>(time() - $pay["CreateTime"]*1))));
          }
          exit();
        }
    

    クライアントの実装

    クライアント側では、一定時間ごとにサーバーへfetchを行って、支払いが完了しているか確認します。

    <!DOCTYPE html>
    <html lang="ja" itemscope="" itemtype="http://schema.org/WebPage" dir="ltr">
      <head>
        <meta charset="UTF-8">
        <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no">
        <title><?=htmlspecialchars($row["Title"])?> - DataCoinTrade</title>
        <meta name="author" content="ActiveTK.">
        <meta name="robots" content="All">
        <meta name="description" content="Bitcoinでファイルやテキストを販売したり、購入することができます。">
        <meta name="copyright" content="Copyright &copy; 2023 ActiveTK. All rights reserved.">
        <script src="https://cdn.tailwindcss.com"></script>
        <script src="https://code.activetk.jp/ActiveTK.min.js"></script>
        <script src="https://code.activetk.jp/jquery-qrcode.min.js"></script>
        <script src="https://unpkg.com/[email protected]/dist/core.js"></script>
        <script>
          window.stInt = null;
          window.stat = localStorage.getItem("<?=$row["UniqueID"]?>") ? localStorage.getItem("<?=$row["UniqueID"]?>") : "";
          document.addEventListener("DOMContentLoaded", function() {
            _("main").style.display = "block";
            if (localStorage.getItem("<?=$row["UniqueID"]?>")) {
              window.stat = localStorage.getItem("<?=$row["UniqueID"]?>");
              _("paymentScript").style.display = "block";
              _("buy").style.display = "none";
              _("buyid").innerText = localStorage.getItem("<?=$row["UniqueID"]?>");
              updateStatus();
              window.stInt = setInterval('updateStatus()', 5000);
            }
            _("buy").onclick = function() {
              _("paymentScript").style.display = "block";
              _("buy").style.display = "none";
              updateStatus();
              window.stInt = setInterval('updateStatus()', 5000);
            }
            new Typewriter(_("dot"), {
              loop: true,
              delay: 75,
              autoStart: true,
              cursor: '|',
              strings: ['']
            });
          });
          function updateStatus() {
            fetch('/<?=$row["UniqueID"]?>?status=' + window.stat)
             .then((response) => response.json())
             .then((data) => ParseStatus(data));
          }
          function ParseStatus(data) {
            if (data["error"])
              alert("エラー: " + data["error"]);
            if (data["newsessid"]) {
              window.stat = data["newsessid"];
              localStorage.setItem("<?=$row["UniqueID"]?>", window.stat);
              _("buyid").innerText = data["newsessid"];
            }
            if (data["done"]) {
              _("buy").style.display = "none";
              _("download").style.display = "block";
              _("download").onclick = function() {
                window.location.href = "https://datacointrade.com/<?=$row["UniqueID"]?>?download=" + data["done"];
              }
              _("stat").innerHTML = "トランザクションが承認されました!";
              clearInterval(window.stInt);
            }
            if (data["address"]) {
              if (_("btcaddr").innerText != data["address"]) {
                _("btcaddr").innerText = data["address"];
                $('#qrcode').qrcode({width: 256, height: 256, text: 'bitcoin:' + data["address"]});
              }
            }
            if (data["amount"]) {
              _("amount").innerText = data["amount"] + " BTC";
            }
            if (data["time"]) {
              _("timer").innerText = data["time"];
            }
          }
        </script>
    </head>
    <body style="background-color:#CCFF99;">
      <div class="pt-4 sm:pt-10 lg:pt-12" style="display:inline;">
        <header class="mx-auto max-w-screen-2xl px-4 md:px-8">
          <div class="flex flex-col items-center justify-between gap-4 py-6 md:flex-row">
            <nav class="flex flex-wrap justify-center gap-x-4 gap-y-2 md:justify-start md:gap-6">
              <a href="/" class="transition duration-100 hover:text-indigo-500 active:text-indigo-600">ホーム</a>
              <a href="/license" class="transition duration-100 hover:text-indigo-500 active:text-indigo-600">利用規約</a>
              <a href="https://profile.activetk.jp/" class="transition duration-100 hover:text-indigo-500 active:text-indigo-600">開発者</a>
              <a href="https://www.activetk.jp/contact" class="transition duration-100 hover:text-indigo-500 active:text-indigo-600">お問い合わせ</a>
            </nav>
            <div class="flex gap-4">
              寄付: 1hackerMy1mcFbMu32ZuiQCvkqQMFnNvX
            </div>
          </div>
        </header>
      </div>
      <hr style="background-color:#000000;height:2px;">
      <br>
      <h1 class="text-3xl font-bold" align="center">
        <?=htmlspecialchars($row["Title"])?> - DataCoinTrade
      </h1>
      <br>
      <noscript><div align="center"><h1>このページを表示するには、JavaScriptを有効化して下さい。</h1></div></noscript>
      <div class="bg-white py-6 sm:py-8 lg:py-12" id="main" style="display:none;">
        <div align="center">
          <h1 class="text-3xl font-bold">【ファイルの情報】</h1>
          <br>
          <p>ファイル名: <?= (file_exists("/home/www-data/crypto/" . $row["UniqueID"] . "/filename") ? htmlspecialchars(file_get_contents("/home/www-data/crypto/" . $row["UniqueID"] . "/filename")) : "未指定") ?></p>
          <p>ファイルサイズ: <?= (file_exists("/home/www-data/crypto/" . $row["UniqueID"] . "/filesize") ? byte_format(htmlspecialchars(file_get_contents("/home/www-data/crypto/" . $row["UniqueID"] . "/filesize"))*1, 2, true) : "未指定") ?></p>
          <p>連絡先: <?=htmlspecialchars($row["ContactEmail"])?></p>
          <p>金額: <?=getDec( $value)?> BTC</p>
          <br>
          <input type="button" id="buy" class="inline-block rounded-lg bg-indigo-500 px-8 py-3 text-center text-sm font-semibold text-white outline-none ring-indigo-300 transition duration-100 hover:bg-indigo-600 focus-visible:ring active:bg-indigo-700 md:text-base" value="データを購入">
          <input type="button" id="download" style="display:none;" class="inline-block rounded-lg bg-indigo-500 px-8 py-3 text-center text-sm font-semibold text-white outline-none ring-indigo-300 transition duration-100 hover:bg-indigo-600 focus-visible:ring active:bg-indigo-700 md:text-base" value="データをダウンロード(購入済み)">
    
          <br><br>
          <div class="bg-white py-6 sm:py-8 lg:py-12" style="background-color:#e6e6fa;text:#363636;display:none;" id="paymentScript">
            <div class="mx-auto max-w-screen-xl px-4 md:px-8">
              <div class="grid gap-8 md:grid-cols-2 lg:gap-12">
                <div> 
                  <div class="h-64 overflow-hidden rounded-lg bg-gray-100 shadow-lg md:h-auto" id="qrcode"></div>
                </div>
                <div class="md:pt-8">
                  <p class="text-center font-bold md:text-left">以下のBitcoinアドレスまで、指定の金額を送金して下さい(決済ID: <span id="buyid"></span>)。</p>
                  <p class="text-center font-bold md:text-left">ただし、金額にトランザクション手数料(Fee)は含まれず、送金金額に不足がある場合には正常に処理できません。</p>
                  <br>
                  <p class="text-center font-bold text-indigo-500 md:text-left" id="amount"></p>
                  <h1 class="mb-4 text-center text-2xl font-bold text-gray-800 sm:text-3xl md:mb-6 md:text-left" id="btcaddr"></h1>
                  <br>
                  <p class="text-center font-bold md:text-left"><span id="stat">トランザクションを待機中..<span id="dot"></span> (<span id="timer">0</span>秒経過)</span></p>
               </div>
              </div>
            </div>
          </div>
        </div>
      </div>
      <div class="bg-white pt-4 sm:pt-10 lg:pt-12">
        <footer class="mx-auto max-w-screen-2xl px-4 md:px-8">
          <div class="py-8 text-center text-sm text-gray-400">(c) 2023 ActiveTK.</div>
        </footer>
      </div>
    </body>
    </html>