🥫

ビットコインの自動販売機を作ろう

に公開

これはNostr Advent Calendar 2025の11日目の記事です。
https://adventar.org/calendars/12046

Nostrとビットコインの LightningNetwork とカストディアルウォレットでかんたんにQRコード決済の自動販売機を作ることができます。
急に「ビットコインの自動販売機をつくって」と頼まれた人はまずこの記事を読みましょう。

自動販売機に必要な要件

まず自動販売機について考えてみましょう。自動販売機が自動販売機である条件はいろいろありますが最低限これは必要でしょう。

  • 顧客の注文を受けつける
  • 顧客に支払い方法を提供する
  • 支払うと商品が出てくる

これを満たす機能をビットコインを前提としてフローチャートで書くと以下のような流れになります。

いきなりLightningNetwork という単語が出てきました。
知らない人のためにざっくり説明するとビットコインは支払いの完了・確定まで約10分間かかり、手数料も比較的高いという課題がありましたが、 LightningNetwork という技術を使うことで一瞬で支払いが完了し、また手数料も非常に安く抑えることが可能になります。

初心者でもわかる「ライトニングネットワーク」とは|特徴と仕組みを解説

自動販売機にとってつらい点

フローチャートからわかるように顧客が支払いを行うと支払い完了確認をして商品を出す必要があります。
この確認は自動販売機側で行う必要がありますが、では支払いが行われたことを検知するにはどういう方法があるでしょうか?

L402というビットコインのLightningNetworkを使ったインターネットでビットコインを支払うプロトコルがあります。

認証フローについて詳しくはこちらのシーケンス図を見ていただきたいんですが簡略化すると以下のような流れになります。

  1. 初回リクエスト: クライアントがAPIにアクセス
  2. 支払い要求: サーバーが402エラーとLightningインボイスを返す
  3. 支払い実行: クライアントがLightning Networkで支払い
  4. 支払い証明取得: 支払い完了後にpreimageを取得
  5. 認証済みリクエスト: 支払い証明をヘッダーに含めて再リクエスト
  6. データ取得: サーバーが支払いを確認してデータを返す

これによりAPIキーなどを使わないでインターネット上のリソースに課金することが可能になります。

ここで問題なのは5の認証済みリクエストです。

これを自動販売機で置き換えて考えると、イメージとしては支払ったあとに再度ボタンを押し、支払い番号を入力する。押して支払いが完了していたら商品が出てくる。みたいな感じになります。
これは結構つらいです。つらい点をリストにしてみます。

  1. 二回ボタン押すのでSUICAよりUXがわるい。支払ったらリアルタイムにすぐに商品が出てきてほしい。
  2. 商品番号を入力するのはさらにUXが悪い。しかしL402ではLNのインボイス固有の支払い番号的なもの(preimage)を渡さないとその支払いを特定できない。
  3. そもそも LightningNetwork のノード建てて維持するのは結構大変でハードルが高い

Web前提のL402の仕組みをそのまま自動販売機には使用できないということがわかりました。

しかしこれらのつらい点をカバーする技術やプロトコルはすでに存在します。一つ一つ見ていきましょう。

1. リアルタイム性を確保する

NostrのZapを支払い検知に使うことでひとまず支払い検知のリアルタイム性を確保することができます。ひとまず以下の点を押さえれば大丈夫です。

  • NostrプロトコルにZapというのがありビットコインの支払いをリアルタイムで通知できる
  • 支払いの検証や証明には使えない
  • 前提としてあくまでお楽しみ機能であり、SNSのクライアントにTip(スパチャ)数を表示する程度の利用を想定している
  • だがWebSocketで飛んでくるのでとにかくめちゃくちゃはやい

詳しく知りたければこちらの記事NIP-57を参照してください。

2. preimageの受け渡し無しで支払いを特定する

ビットコインの LightningNetwork には LNURL という拡張があり、これにより静的なアドレスにたいして支払いを行うことが可能になります。
ビットコイン用のメールアドレスのようなものだと思ってください。

以下のような形式になります。

LNURL: LNURL1DP68GURN8GHJ7UM9WFM...YXYMNSERXFQ5FNS
LightningAddresses形式: ocknamo@hogehoge.ln.com

さて、この LNURL にはコメントの仕様があります。

LUD12

このコメントに独自のユニークな支払いIDを入れてインボイスを自動販売機が発行し、そのインボイスに対して支払いを行わせれば、この支払いIDが受取先のLNURLサーバに保管されるのでpreimageがなくても識別が可能になります。
じつはこのコメントですが LightningNetwork のネットワークを使わないでメッセージを送ることができるという天才の考えた仕様になっています。

流石にこの説明ではわけがわからないと思うので順序立てて説明しましょう。

  1. 自動販売機が生成したIDを LNURL 中の callbackUrl にクエリとして渡す
  2. 顧客が LNURL 対応済みのウォレットでそのQRコードを読み取ると(特に意識せずとも)自動的にそのクエリを含むリクエストが LNURL サーバに送られる
  3. LNURLサーバがコメント受取りそれに対してインボイスを発行して返す(インボイス自体にはコメントは含まれない)
  4. 顧客が支払いを実行する
  5. preimage(とそれをハッシュ化したpayment_hash)で支払い元のインボイスを紐付けられるので、独自の支払いIDと支払い結果がコメントにより紐づく

全体としては L402で サーバ自体がやっていた preimage の検証を LNURL サーバーに肩代わりさせている構成になっているといえます。

3. カストディアルウォレットを使う

最後に LightningNetwork のノードについてです。企業でサービスとしてやるのであればともかく、個人開発レベルで自動販売機を作る場合、LightningNetwork のノードを立てて維持するのはそれなりにハードルが高いので、セキュリティのトレードオフはありますが、普通に誰かがノードを管理してくれるカストディアルウォレットを使うのがとりあえずは楽でいいと思います。

カストディアルウォレットの条件としてLNURL対応でかつNostrのZapにも対応しており、かつAPIが利用できるものである必要があります。
そんな都合がいいウォレットですが、Coinosが使えます。別に他のウォレットでも構わないんですがAPI機能があるものがあんまり見つからないですね。

トレードオフとしてCoinosのセキュリティを信頼する必要があります。平たく言うとお金を預ける状態になります。(最近見たらノンカストディアルアカウントの設定が生えてたけどよくわからないので説明を省略)

リスクを低減する方法ですが、 Coinosには自動出金機能があります。ある程度溜まったら信頼できるウォレットなどに自動出金させれば Coinos においておく資金を低くたもてるため流出リスクを低減できます。

その他フォールバック戦略

Coinosを信頼するのは仕方がないのですが、NostrネットワークとそのZapはお金を預けるほどには信頼できないもしくはすべきでないです。
リレーの運用等がボランティアの有志で行われているためあまり負担や責任をもたせるべきでない点や、それに加えてリレーとのコネクションを安定的に維持できるかなど不確定要素が多いためです。

フォールバック戦略としては単純にウォレットのAPIにポーリングする形で考えてみます。

構成

システム構成図は以下のようになります。

ボタンを押してからQRコードの発行まで、利用者からすれば数秒ですがいろいろなことをやっています。

  • kind 1イベント送信
  • Zapリクエスト送信
  • Lightning Invoiceの発行

kind1イベントはZapの対象としてNostrプロトコル上必要なため使い捨てで発行します。Nostr上でZapできれば良いのでkind0や何かしら固定のイベントでも問題ありません。
ただ受取残高は機密事項なので使い捨てのイベントにしてネットワークにも伝播させなくても問題ないかと思います。
kind1をターゲットにZapリクエスト(kind-9734)を作成してそれを含むインボイスを作成します。支払いIDはこのkind-9734イベントのcontentという領域に入れます。

あとは利用者が支払いを実行すればZapのイベント(kind-9735)が Nostr のネットワークに伝播し、支払いを検知することができます。
ここでは検証部分は省略してしまっていますが、支払いを検知したら支払金額と支払いIDを検証し問題なければ商品を提供します。

実際のコード例

代表的な部分のコード例を紹介しておきます。

nostr-toolsという NostrのライブラリがあるのでNostr関係する箇所はそれを利用しているかラップした関数を使用しています。

ボタンを押してからQRコード発行まで

async function generateQRCode() {
  try {
    // 1. Nostr秘密鍵をデコード
    const privateKeyBytes = decodeNsec(nostrPrivateKey);

    // 2. Nostr kind 1イベントを作成・送信
    const textEvent = createTextEvent(privateKeyBytes, '');

    // 3. recipientのmetadata eventを作成(簡易版)
    // 実際のアプリではリレーから取得するが、ここでは設定値から作成
    const recipientPubkey = textEvent.pubkey; // 自分自身にzapする場合
    const metadataEvent = createMetadataEvent(recipientPubkey, lightningAddress);

    // 4. zapUrl取得
    const zapUrl = await nip57.getZapEndpoint(metadataEvent);
    if (zapUrl === null) {
      errorMessage = `Zapエンドポイントが見つかりません。ライトニングアドレス: ${lightningAddress}`;
      throw new Error(`Zapエンドポイントが見つかりません。ライトニングアドレス: ${lightningAddress}`);
    }

    // 5. ランダムな8byte値を生成
    const paymentId = generateRandomBase64();
    console.log('[Fortune Slip] Generated payment ID:', paymentId);

    // 1 sat = 1000 millisats
    const satsAmount = zapAmount * 1000;

    // 6. Zapリクエストを作成(ランダム値をcommentに埋め込む)
    const zapRequest = createZapRequest(
      privateKeyBytes,
      textEvent, // 完全なeventオブジェクト
      satsAmount,
      paymentId, // 識別用IDを埋め込む
    );

    // 6. Zapインボイスを取得
    const invoice = await getZapInvoiceFromEndpoint(zapUrl, satsAmount, zapRequest);

    // 7. QRコードを生成
    qrCodeDataUrl = await generateLightningQRCode(invoice.pr);

    // 9. Zap検知を開始
    currentZapRequest = zapRequest;
    currentTargetEventId = textEvent.id;

    zapSubscription = subscribeToZapReceipts(
      textEvent.id,
      zapRequest,
      onZapDetected,
      300000, // 5分タイムアウト
      coinosApiToken, // Coinos API Token(オプション)
      onZapError, // エラーコールバック
    );

    // 10. Coinos APIポーリングを開始(トークンが設定されている場合のフォールバック)
    console.log('[Fortune Slip] Starting Coinos polling as fallback');
    coinosPollingSubscription = startCoinosPolling(
      paymentId,
      coinosApiToken,
      onCoinosPaymentDetected,
      10000, // 10秒間隔
      300000, // 5分タイムアウト
    );

  } catch (error) {
    console.error('QR code generation failed:', error);
    errorMessage = error instanceof Error ? error.message : 'QRコードの生成に失敗しました。';
  }
}

Zapの検知

nostr-toolsのSimplePoolを使ってkind-9735イベントを購読しています。イベント検出後、支払いの検証を行います。

/**
 * 特定のイベントに対するZap Receipt (kind 9735)を監視
 * QRコード表示直後から開始し、zapが検知されたらコールバックを実行
 * Coinos API検証も含む
 */
export function subscribeToZapReceipts(
  targetEventId: string,
  zapRequest: NostrEvent,
  onZapReceived: (zapReceipt: NostrEvent) => void,
  timeoutMs: number = 300000, // 5分のタイムアウト
  coinosApiToken?: string, // Coinos API Token(オプション)
  onZapError?: (error: string) => void, // エラーコールバック(オプション)
): ZapReceiptSubscription {
  const pool = new SimplePool();
  const subscriptionId = `zap-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;

  console.log(`[Zap Monitor] Starting subscription for event: ${targetEventId}`);
  console.log(`[Zap Monitor] Coinos verification enabled:`, !!coinosApiToken);

  // フィルターを正しいFilter型で作成
  const filter: Filter = {
    kinds: [9735],
    since: Math.floor(Date.now() / 1000) - 60,
    '#e': [targetEventId], // Filter型のindex signatureを使用
  };

  console.log(`[Zap Monitor] Filter:`, JSON.stringify(filter, null, 2));

  // サブスクリプション開始 - 正しい型を使用
  const subscription = pool.subscribeMany(
    RELAYS,
    filter, // 単一のFilterオブジェクト
    {
      onevent: async (event: Event) => {
        console.log(`[Zap Monitor] Received zap receipt:`, event);

        const zapReceipt = event as NostrEvent;

        try {
          // Coinos API検証を含む総合的な検証を実行
          const verificationResult = await validateZapReceiptWithCoinos(
            zapReceipt,
            targetEventId,
            zapRequest,
            coinosApiToken,
          );

          if (verificationResult.valid) {
            console.log(`[Zap Monitor] Valid zap receipt detected for event: ${targetEventId}`);
            if (verificationResult.coinosVerified) {
              console.log(`[Zap Monitor] Coinos verification also passed`);
            }
            onZapReceived(zapReceipt);
          } else {
            console.warn(`[Zap Monitor] Invalid zap receipt for event: ${targetEventId}`, verificationResult.error);
            // Coinos検証失敗の場合、エラーコールバックを呼び出す
            if (
              verificationResult.error &&
              verificationResult.error.includes('Coinos verification failed') &&
              onZapError
            ) {
              onZapError(verificationResult.error);
            }
          }
        } catch (error) {
          console.error(`[Zap Monitor] Error during zap receipt verification:`, error);
          // 検証エラーの場合もエラーコールバックを呼び出す
          if (onZapError) {
            const errorMessage = error instanceof Error ? error.message : String(error);
            onZapError(`Zap verification error: ${errorMessage}`);
          }
        }
      },
      oneose: () => {
        console.log(`[Zap Monitor] End of stored events for subscription: ${subscriptionId}`);
      },
      onclose: (reasons: string[]) => {
        console.log(`[Zap Monitor] Subscription closed:`, reasons);
      },
    },
  );

  // タイムアウト設定
  const timeoutId = setTimeout(() => {
    console.log(`[Zap Monitor] Subscription timeout for event: ${targetEventId}`);
    subscription.close();
    pool.close(RELAYS);
  }, timeoutMs);

  // 停止関数
  const stop = () => {
    console.log(`[Zap Monitor] Stopping subscription for event: ${targetEventId}`);
    clearTimeout(timeoutId);
    subscription.close();
    // 少し待ってからプールを閉じる
    setTimeout(() => {
      pool.close(RELAYS);
    }, 1000);
  };

  return {
    pool,
    subscriptionId,
    eventId: targetEventId,
    onZapReceived,
    stop,
  };
}

支払いの検証

支払いの検証の実装です。Zapでイベントが来る場合はイベント自体のIDを比較してしまっていて支払いIDでの比較を省略しています。(支払いIDがイベントに含まれるため)
パースして支払IDを抽出して検証する実装でも良かったかなと思います。

/**
 * Zap Receiptの妥当性を検証(Coinos API検証付き)
 * NIP-57 Appendix Fの仕様に基づく基本検証とCoinos APIによる支払い検証を行う
 */
export async function validateZapReceiptWithCoinos(
  zapReceipt: NostrEvent,
  targetEventId: string,
  zapRequest: NostrEvent,
  coinosApiToken?: string,
): Promise<{ valid: boolean; coinosVerified?: boolean; error?: string }> {
  try {
    // まず基本的なNostr検証を実行(正しい形式のイベントかなど)
    const basicValid = validateZapReceipt(zapReceipt, targetEventId, zapRequest);

    if (!basicValid) {
      return {
        valid: false,
        error: 'Basic zap receipt validation failed',
      };
    }

    // Coinos API検証を実行
    console.log('[Zap Verification] Starting Coinos API verification');
    const coinosResult = await verifyCoinosPayment(zapReceipt, coinosApiToken);

    if (coinosResult.verified) {
      console.log('[Zap Verification] Both Nostr and Coinos verification passed');
      return {
        valid: true,
        coinosVerified: true,
      };
    } else {
      // Coinos検証に失敗した場合はエラーとして扱う
      console.error('[Zap Verification] Coinos verification failed:', coinosResult.error);
      return {
        valid: false,
        coinosVerified: false,
        error: `Coinos verification failed: ${coinosResult.error}`,
      };
    }
  } catch (error) {
    console.error('[Zap Verification] Unexpected error during verification:', error);
    return {
      valid: false,
      error: `Verification error: ${error instanceof Error ? error.message : String(error)}`,
    };
  }
}


/**
 * Coinos APIでZap支払いを検証
 * @param zapReceipt Zapレシートイベント
 * @param token Coinos Read-Only APIトークン
 * @param timeWindowMs 検証対象の時間窓(デフォルト: 10分)
 * @returns Promise<CoinosVerificationResult>
 */
export async function verifyCoinosPayment(
  zapReceipt: NostrEvent,
  token: string,
  timeWindowMs: number = 600000, // 10分
): Promise<CoinosVerificationResult> {
  try {
    // Zapレシートからpreimageを抽出
    const zapData = extractZapReceiptData(zapReceipt);
    if (!zapData) {
      return {
        verified: false,
        error: 'Unable to extract preimage from zap receipt',
      };
    }

    // Coinos APIから支払い履歴を取得
    const paymentsData = await getCoinosPayments(token, 10); // 最大10件取得

    // Zapレシートの作成時刻を基準にした時間窓(10分前から今まで)を設定
    const zapTimestamp = zapReceipt.created_at * 1000; // ミリ秒に変換
    const windowStart = zapTimestamp;
    const windowEnd = zapTimestamp + timeWindowMs;

    console.debug('[Coinos Verification] Looking for payment with preimage:', zapData.preimage);
    console.debug('[Coinos Verification] Time window:', new Date(windowStart), 'to', new Date(windowEnd));

    // preimageが一致する支払いを検索
    const matchedPayment = paymentsData.payments.find((payment) => {
      // preimage(ref)の一致確認
      if (payment.ref !== zapData.preimage) {
        return false;
      }

      // 時間窓内の支払いかチェック
      const paymentTime = payment.created;
      if (paymentTime < windowStart || paymentTime > windowEnd) {
        console.debug('[Coinos Verification] Payment time outside window:', new Date(paymentTime));
        return false;
      }

      // 確認済みの支払いかチェック
      if (!payment.confirmed) {
        console.debug('[Coinos Verification] Payment not confirmed yet');
        return false;
      }

      return true;
    });

    if (!matchedPayment) {
      return {
        verified: false,
        error: 'No matching confirmed payment found in Coinos API',
      };
    }

    console.log('[Coinos Verification] Matched payment found:', matchedPayment.id);

    // 追加検証: memoにzap requestの情報が含まれているかチェック(オプション)
    if (matchedPayment.memo) {
      try {
        const memoData = JSON.parse(matchedPayment.memo);
        if (memoData.id) {
          // descriptionタグからzap requestのIDを取得
          const descriptionTag = zapReceipt.tags.find((tag) => tag[0] === 'description');
          if (descriptionTag) {
            const zapRequestData = JSON.parse(descriptionTag[1]);
            if (zapRequestData.id !== memoData.id) {
              console.warn('[Coinos Verification] Zap request ID mismatch in memo');
              // 警告を出すが、検証は成功とする(preimageの一致が最重要)
            }
          }
        }
      } catch (error) {
        // memoのパースに失敗してもエラーにはしない
        console.debug('[Coinos Verification] Unable to parse memo JSON:', error);
      }
    }

    return {
      verified: true,
      matchedPayment,
    };
  } catch (error) {
    if (error instanceof CoinosApiError) {
      return {
        verified: false,
        error: error.message,
      };
    }

    return {
      verified: false,
      error: `Unexpected error during Coinos verification: ${error instanceof Error ? error.message : String(error)}`,
    };
  }
}

まとめ

以上ビットコインによる自動販売機の作成方法でした。急にビットコインの自動販売機作ってといわれた人は参考にしてみてください。

実は結局 Nostrasia2025で公開していた Nostrおみくじの説明になっています。
実際の実装やアプリはこちらで見ることができるので気になったら参考にしてください。(こちらは自動販売機ではなくおみくじというていですが)

カストディアルウォレットのAPIが Webhook に対応してくれれば Nostr は必要はないかもしれない?はいそのとおりです。

最後に注意点ですが Coinos自体がどれくらいの負荷をゆるしてもらえるのか不明なのであまり本格的に使用するべきではなく、ようするに常識的な利用にしましょう。
あくまで個人開発のレベルにとどめて数千人から支払われるようなユースケースで Coinos APIを叩くのはやめておいたほうが無難だと思います。

本格的に使いたい場合Coinosは実装が公開されているのでセルフホストもできるっぽいので検討してみましょう。(試してはいませんが)

Discussion