8
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?

【Symbol】初めてのブロックチェーン開発でも作れる!Webウォレットの仕組みをコンポーネントごとに徹底解説

Last updated at Posted at 2025-12-11

この記事を読めば、自分でSymbolのWebウォレットを作れるようになります。

本記事は、NEMTUSハッカソンで「初めてブロックチェーンを触る方向け」に書いています。

難しい理論はなるべく排除して、コードが"何をしているのか"を部品ごとに理解できる構成にしています。

✨ この記事で作れるようになるもの

  • ✅ Symbolノードへの自動接続
  • ✅ SSS Extensionを使った安全な署名
  • ✅ アカウント残高の取得
  • ✅ XYM送金トランザクション
  • ✅ WebSocketを使ったリアルタイムTX更新
  • ✅ 未承認→承認の自動昇格表示
  • ✅ サウンド通知
  • ✅ NodeWatchを使った自動ノード選択

👀 どんなWebウォレット?

実際に動いているところを YouTube で見てみましょう📺

動画サムネイル

アプリはこちら💁‍♂️

Githubはこちら💁‍♀️

リポジトリをフォークしていろいろ機能を追加してみてください。


SSS Extension という Chrome拡張機能 のインストールが必要です。

SSS Extension の使い方


参考記事 : Symbol デスクトップウォレットのセットアップの仕方

XYM Faucet から テスト用の XYM をもらう事が出来ます。

🎯 想定読者

  • JavaScriptの基本は理解している
  • ブロックチェーン開発は初めて
  • SymbolでウォレットやdAppを作りたい
  • とにかく動くものを作って理解したい

📚 全体構成:どのファイルが何を担当する?

ウォレットは以下の 14個のモジュールで構成されています(CSS/HTML含む):

ファイル名 役割 重要度
index.html 画面レイアウト、カードUI、JS/CSS読み込み ⭐⭐⭐
css/base.css 全体のベーススタイル ⭐⭐
css/wallet.css カードUI、TXカード等の専用CSS ⭐⭐⭐
js/config.js 状態管理(NODE、epoch、ネットワーク) ⭐⭐⭐
js/index.js 初期化処理、イベント登録、LiveTx/WS開始 ⭐⭐⭐
js/sss.js SSS接続・ネットワーク判定・ノード選択 ⭐⭐⭐
js/nodeSelector.js NodeWatchから優良ノード選択 ⭐⭐
js/sdk.js SDK読み込み & Facade初期化 ⭐⭐⭐
js/account.js 残高取得 ⭐⭐
js/transfer.js 送金処理(SSS署名) ⭐⭐⭐
js/transactions.js TX履歴・未承認→承認昇格・カード生成 ⭐⭐
js/ws.js WebSocket通知・サウンド制御 ⭐⭐
js/utils.js ポップアップ・サウンド
js/ui.js ステータス表示などUI補助

🧩 各コンポーネントを徹底解説

1️⃣ config.js — アプリ全体の状態管理

ここがウォレットの"中枢神経"です。

// config.js
// 設定値とアプリ全体で共有する状態

// NodeWatch エンドポイント
export const MAINNET_NODEWATCH_URL =
  "https://nodewatch.symbol.tools/api/symbol/nodes/peer?only_ssl=true&limit=10&order=random";

export const TESTNET_NODEWATCH_URL =
  "https://nodewatch.symbol.tools/testnet/api/symbol/nodes/peer?only_ssl=true&limit=10&order=random";

// NodeWatch が落ちているとき用の fallback ノード
export const MAINNET_FALLBACK_NODES = [
  "https://sym-main-01.opening-line.jp:3001",
  "https://sym-main-02.opening-line.jp:3001",
  // ...
];

export const TESTNET_FALLBACK_NODES = [
  "https://401-sai-dual.symboltest.net:3001",
  // ...
];

// アプリ全体で共有する状態
export const appState = {
  NODE: null,              // 接続中のノード
  networkType: null,       // MAINNET or TESTNET
  currentAddress: null,    // 現在のアドレス
  currentPubKey: null,     // 現在の公開鍵
  sdkCore: null,          // Symbol SDK core
  sdkSymbol: null,        // Symbol SDK symbol
  facade: null,           // SymbolFacade インスタンス
  epochAdjustment: null,  // エポック調整値
  isSdkReady: false       // SDK初期化完了フラグ
};

// ネットワーク種別の定数
export const NetworkType = {
  MAINNET: 104,
  TESTNET: 152
};

// XYM の MosaicId をネットワークに応じて返す
export function getXymMosaicIdHex() {
  return appState.networkType === NetworkType.TESTNET
    ? "72C0212E67A08BCE"  // TestnetのXYM
    : "6BED913FA20223F8"; // MainnetのXYM
}

🔍 このファイルのポイント

  1. NodeWatch URL: 優良ノードを自動取得するAPIエンドポイント
  2. Fallback Nodes: NodeWatchが落ちていても接続できるバックアップ
  3. appState: アプリ全体で使う「グローバル変数」を1箇所で管理
  4. getXymMosaicIdHex(): ネットワークごとに異なるXYMのIDを自動判別

なぜappStateが必要?
JavaScriptのモジュール間でデータを共有するため、1つのオブジェクトに状態を集約します。これにより、どのファイルからもappState.NODEのように参照できます。


2️⃣ index.js — 画面読み込み時の初期化

ブラウザがロードした瞬間に走る「エントリーポイント」です。

// index.js
import { appState } from "./config.js";
import { autoConnectSSS } from "./sss.js";
import { refreshAccount } from "./account.js";
import { sendTx } from "./transfer.js";
import { loadRecentTx, initLiveTx } from "./transactions.js";
import { initWebSocket } from "./ws.js";
import { initSdk } from "./sdk.js";
import { showPopup } from "./utils.js";

window.addEventListener("load", async () => {

  // ① まず SSS 接続 / ノード選択(ネットワーク判別に必要)
  await autoConnectSSS();

  // ② SDK を初期化
  await initSdk();

  // ========= イベント登録 =========

  document.getElementById("refresh-account")
    ?.addEventListener("click", refreshAccount);

  document.getElementById("btn-transfer")
    ?.addEventListener("click", sendTx);

  document.getElementById("reload-tx")
    ?.addEventListener("click", loadRecentTx);

  document.getElementById("copy-address-btn")?.addEventListener("click", () => {
    const addr = document.getElementById("account-address").textContent;
    navigator.clipboard.writeText(addr);
    showPopup("アドレスをコピーしました!");
  });

  // ③ 初回の残高取得
  await refreshAccount();

  // ④ 取引履歴の初回読み込み
  await loadRecentTx();

  // ⑤ WebSocket接続(リアルタイム更新)
  if (appState.currentAddress) {
    initWebSocket(appState.currentAddress.toString());
  }

  // ⑥ リアルタイムTX監視開始
  initLiveTx();
});

🔍 初期化の流れ

💡 なぜこの順番?

  1. SSS接続が最優先: ユーザーのアカウント情報がないと何もできない
  2. ネットワーク判定→ノード選択: MainnetかTestnetかでノードが変わる
  3. SDK初期化: トランザクション作成に必要
  4. 残高・履歴取得: ユーザーに情報を表示
  5. WebSocket: リアルタイム更新の準備

3️⃣ nodeSelector.js — NodeWatchを使ったノード選択

Symbolは分散ノードなので、どのノードに接続するかが超重要。

// nodeSelector.js
import {
    MAINNET_NODEWATCH_URL,
    TESTNET_NODEWATCH_URL,
    MAINNET_FALLBACK_NODES,
    TESTNET_FALLBACK_NODES,
} from "./config.js";

function pickRandom(list) {
    return list[Math.floor(Math.random() * list.length)];
}

export async function selectNode(isTestnet) {
    const infoEl = document.getElementById("node-info");

    const NODEWATCH_URL = isTestnet
        ? TESTNET_NODEWATCH_URL
        : MAINNET_NODEWATCH_URL;
    const FALLBACKS = isTestnet 
        ? TESTNET_FALLBACK_NODES 
        : MAINNET_FALLBACK_NODES;

    infoEl.textContent = "NodeWatchからノード選択中…";

    // 1.5秒でタイムアウト
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), 1500);

    try {
        const res = await fetch(NODEWATCH_URL, { signal: controller.signal });
        clearTimeout(timeoutId);

        const nodes = await res.json();
        if (!Array.isArray(nodes) || nodes.length === 0) {
            throw new Error("NodeWatch empty");
        }

        // ブロック高が最も進んでいるノードを選択
        let bestNode = nodes[0];
        for (const node of nodes) {
            if (node.height > bestNode.height) {
                bestNode = node;
            }
        }

        const nodeUrl = `https://${bestNode.host}:3001`;
        infoEl.textContent = `接続: ${bestNode.host} (高さ: ${bestNode.height})`;
        return nodeUrl;

    } catch (err) {
        console.warn("NodeWatch失敗、Fallbackノード使用:", err);
        
        const fallbackNode = pickRandom(FALLBACKS);
        infoEl.textContent = `Fallback接続: ${fallbackNode}`;
        return fallbackNode;
    }
}

🔍 このファイルのポイント

  1. NodeWatch API: 優良ノードリストを取得
  2. ブロック高で判定: 最も同期が進んでいるノードを選択
  3. タイムアウト処理: 1.5秒で諦めてFallbackへ
  4. Fallback機構: NodeWatchが落ちていても動作保証

💡 なぜブロック高で選ぶ?

ブロック高が低いノードは同期が遅れているため、最新の取引情報が取れません。常に最新のブロックを持つノードを選ぶことが重要です。


4️⃣ sdk.js — Symbol SDKの読み込みとFacade初期化

SDKを動的importし、ネットワークプロパティからFacadeを初期化します。

// sdk.js
import { appState } from "./config.js";

const SDK_VERSION = "3.3.0";

export async function initSdk() {

  if (!appState.NODE) {
    throw new Error("NODEが未設定です");
  }

  // ================================
  //   Symbol SDK 読み込み
  // ================================
  const sdk = await import(
    `https://unpkg.com/symbol-sdk@${SDK_VERSION}/dist/bundle.web.js`
  );

  appState.sdkCore = sdk.core;
  appState.sdkSymbol = sdk.symbol;

  // ================================
  //   ネットワークプロパティ取得
  // ================================
  const props = await fetch(new URL("/network/properties", appState.NODE))
    .then(r => r.json());

  // epochAdjustment を取得(タイムスタンプ計算に必須)
  const epochRaw = props.network.epochAdjustment;
  appState.epochAdjustment = Number(epochRaw.replace("s", ""));

  // ネットワーク識別子を取得し Facade 初期化
  const identifier = props.network.identifier;
  appState.facade = new appState.sdkSymbol.SymbolFacade(identifier);

  appState.isSdkReady = true;
}

🔍 このファイルのポイント

  1. 動的import: CDNからSDKを読み込み(npm不要)
  2. epochAdjustment: Symbolのタイムスタンプ→Unix時間変換に必須
  3. Facade: トランザクション作成・署名の中心クラス
  4. identifier: MainnetとTestnetで異なる識別子を自動判別

💡 Facadeとは?

Symbol SDKの中心的なクラスで、以下の機能を提供:

  • アカウント生成
  • トランザクション作成
  • 署名処理
  • アドレス変換

5️⃣ sss.js — SSS Extensionとの接続

SymbolのWebアプリで最も重要な「秘密鍵を扱わない方法」です。

// sss.js
import { appState, NetworkType } from "./config.js";
import { selectNode } from "./nodeSelector.js";
import { initSdk } from "./sdk.js";
import { setStatus, setText } from "./ui.js";
import { refreshAccount } from "./account.js";
import { loadRecentTx } from "./transactions.js";

function networkLabel(nt) {
  return nt === NetworkType.TESTNET ? "Testnet" : "Mainnet";
}

let isConnecting = false;
let isConnectedOnce = false;
let lockedNetworkType = null;

async function internalConnect() {
  if (isConnecting) return;
  isConnecting = true;

  try {
    // SSS Extension が存在するか確認
    if (!window.SSS) {
      setStatus("sss-status", "SSS Extensionが見つかりません", "error");
      return;
    }

    setStatus("sss-status", "SSS接続中…");

    // アクティブな公開鍵とネットワーク種別を取得
    const pubKey = await window.SSS.activePublicKey();
    const networkType = await window.SSS.activeNetworkType();

    if (!pubKey) {
      setStatus("sss-status", "SSSでアカウントを選択してください", "error");
      return;
    }

    // 初回接続時にネットワークをロック
    if (!isConnectedOnce) {
      lockedNetworkType = networkType;
      isConnectedOnce = true;
    }

    // ネットワーク種別の変更を検出
    if (lockedNetworkType !== networkType) {
      setStatus("sss-status", 
        `ネットワークが変更されました。ページをリロードしてください。`, 
        "error");
      return;
    }

    appState.networkType = networkType;
    appState.currentPubKey = pubKey;

    // ノード選択
    const isTestnet = networkType === NetworkType.TESTNET;
    const selectedNode = await selectNode(isTestnet);
    appState.NODE = selectedNode;

    // SDK初期化
    await initSdk();

    // Facadeを使ってアドレス生成
    const pubKeyObj = new appState.sdkCore.PublicKey(pubKey);
    appState.currentAddress = appState.facade.network.publicKeyToAddress(pubKeyObj);

    setStatus("sss-status", 
      `接続完了 (${networkLabel(networkType)})`, 
      "success");

    // 残高取得
    await refreshAccount();

    // 取引履歴読み込み
    await loadRecentTx();

  } catch (err) {
    console.error("SSS接続エラー:", err);
    setStatus("sss-status", `接続失敗: ${err.message}`, "error");
  } finally {
    isConnecting = false;
  }
}

export async function autoConnectSSS() {
  await internalConnect();
}

export async function manualConnectSSS() {
  await internalConnect();
}

🔍 このファイルのポイント

  1. window.SSS: Chrome拡張から提供されるグローバルオブジェクト
  2. activePublicKey(): 現在選択中のアカウントの公開鍵を取得
  3. activeNetworkType(): MainnetかTestnetかを判定
  4. ネットワークロック: 途中でTestnet↔Mainnetを切り替えられないようにする
  5. 自動初期化: SSS接続→ノード選択→SDK初期化→残高取得を一気に実行

💡 なぜSSS Extensionを使う?

  • 秘密鍵をWebアプリに渡さない: セキュリティ的に最も安全
  • ユーザー体験: MetaMaskのような使い勝手

6️⃣ account.js — 残高取得

REST APIを使ってアカウント情報を取得します。

// account.js
import { appState, getXymMosaicIdHex } from "./config.js";
import { setStatus } from "./ui.js";

export async function refreshAccount() {
  if (!appState.NODE || !appState.currentAddress) return;

  setStatus("account-status", "残高取得中…");

  try {
    const address = appState.currentAddress.toString();
    document.getElementById("account-address").textContent = address;

    const res = await fetch(
      new URL(`/accounts/${address}`, appState.NODE)
    );
    const data = await res.json();

    const mosaics = data.account.mosaics || [];
    const targetId = getXymMosaicIdHex().toUpperCase();
    let xym = 0;

    // 所持している全Mosaicから XYM だけ抽出
    mosaics.forEach((m) => {
      const idHex = typeof m.id === "string"
        ? m.id.toUpperCase()
        : (m.id?.toString(16) || "").toUpperCase();

      if (idHex === targetId) {
        const raw = Number(m.amount ?? m.quantity ?? 0);
        xym = raw / 1_000_000; // 可分性6
      }
    });

    document.getElementById("account-balance").textContent = 
      xym.toFixed(6) + " XYM";

    setStatus("account-status", "残高取得完了", "success");

  } catch (err) {
    console.error("残高取得エラー:", err);
    setStatus("account-status", "残高取得失敗", "error");
  }
}

🔍 このファイルのポイント

  1. GET /accounts/{address}: REST APIでアカウント情報取得
  2. mosaics配列: Symbolでは複数のトークンを所持可能
  3. XYMの判別: ネットワークごとに異なるMosaic IDで判定
  4. 可分性6: 1 XYM = 1,000,000 マイクロXYM

💡 なぜ1,000,000で割る?

SymbolのXYMは可分性6(小数点以下6桁)なので、APIから返る値は「マイクロXYM」単位。これを1,000,000で割ることで「XYM」単位に変換します。


7️⃣ transfer.js — XYM送金トランザクション

最も実用的な部分。SSS署名の流れを完全理解できます。

// transfer.js
import { appState, getXymMosaicIdHex } from "./config.js";
import { setStatus } from "./ui.js";

export async function sendTx() {
  if (!appState.NODE || 
      !appState.currentAddress || 
      !appState.currentPubKey || 
      !appState.isSdkReady) {
    setStatus("tx-status", "初期化が未完了です。", "error");
    return;
  }

  const recipientRaw = document.getElementById("tx-recipient").value.trim();
  const amountStr = document.getElementById("tx-amount").value;
  const messageText = document.getElementById("tx-message").value || "";

  if (!recipientRaw || !amountStr) {
    setStatus("tx-status", "アドレスと金額は必須です。", "error");
    return;
  }

  const recipientAddress = new appState.sdkSymbol.Address(recipientRaw);
  const amount = Number(amountStr);

  if (Number.isNaN(amount) || amount < 0) {
    setStatus("tx-status", "金額が不正です。", "error");
    return;
  }

  // XYM Mosaic ID を取得
  const mosaicIdHex = getXymMosaicIdHex();
  const mosaicIdBigInt = BigInt("0x" + mosaicIdHex);

  // 可分性6なので 1,000,000倍
  const amountMicro = BigInt(Math.floor(amount * 1_000_000));

  // メッセージをバイト配列に変換
  const messageBytes = new TextEncoder().encode(messageText);

  try {
    setStatus("tx-status", "トランザクション作成中…");

    // ネットワーク情報取得
    const networkRes = await fetch(
      new URL("/network/properties", appState.NODE)
    );
    const networkProps = await networkRes.json();
    const generationHash = networkProps.network.generationHashSeed;

    // トランザクション作成
    const tx = appState.facade.transactionFactory.create({
      type: "transfer_transaction_v1",
      signerPublicKey: appState.currentPubKey,
      recipientAddress: recipientAddress.bytes,
      mosaics: [
        { mosaicId: mosaicIdBigInt, amount: amountMicro }
      ],
      message: new Uint8Array([0, ...messageBytes]) // 0x00 = 平文
    });

    // 手数料設定(100倍で安全マージン)
    tx.fee.value = BigInt(tx.size * 100);

    // トランザクションをシリアライズ
    const txPayloadHex = appState.sdkCore.utils.uint8ToHex(tx.serialize());

    setStatus("tx-status", "SSS署名待ち…");

    // SSS Extensionで署名
    window.SSS.setTransactionByPayload(txPayloadHex);
    const signed = await window.SSS.requestSign();

    if (!signed || !signed.payload) {
      setStatus("tx-status", "署名がキャンセルされました。", "error");
      return;
    }

    setStatus("tx-status", "アナウンス中…");

    // ノードにアナウンス
    const announceRes = await fetch(
      new URL("/transactions", appState.NODE),
      {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ payload: signed.payload })
      }
    );

    if (!announceRes.ok) {
      const errText = await announceRes.text();
      throw new Error(`アナウンス失敗: ${errText}`);
    }

    setStatus("tx-status", 
      "送金成功! 未承認に追加されました。", 
      "success");

    // フォームをクリア
    document.getElementById("tx-recipient").value = "";
    document.getElementById("tx-amount").value = "";
    document.getElementById("tx-message").value = "";

  } catch (err) {
    console.error("送金エラー:", err);
    setStatus("tx-status", `送金失敗: ${err.message}`, "error");
  }
}

🔍 トランザクション作成の流れ

🔍 このファイルのポイント

  1. transactionFactory.create(): SDKでトランザクション作成
  2. 手数料計算: tx.size * 100で安全マージン
  3. SSS署名: window.SSS.setTransactionByPayload()requestSign()
  4. PUT /transactions: 署名済みペイロードをアナウンス

💡 なぜ100倍?

手数料は「1バイトあたりの価格」で計算されます。tx.size * 100は安全マージンを持たせた計算式で、ほぼ確実に承認されます。


8️⃣ transactions.js — 取引履歴&リアルタイム更新

Symbolの面白さが一番出る部分。

// transactions.js
import { appState, NetworkType } from "./config.js";
import { addCallback, getBlockTimestamp } from "./ws.js";

// Symbol timestamp → 人間時間
function formatTimestamp(symbolTimestamp) {
  if (!symbolTimestamp || !appState.epochAdjustment) return "";
  const unixMs = appState.epochAdjustment * 1000 + Number(symbolTimestamp);
  return new Date(unixMs).toLocaleString("ja-JP", { hour12: false });
}

// Hex メッセージ → UTF-8
function decodeMessage(payload) {
  if (!payload) return "(no message)";
  let hex = payload;
  if (hex.startsWith("00")) hex = hex.slice(2); // 平文フラグ削除

  const arr = hex.match(/.{1,2}/g);
  if (!arr) return "(decode error)";

  try {
    const bytes = new Uint8Array(arr.map(h => parseInt(h, 16)));
    return new TextDecoder().decode(bytes);
  } catch {
    return "(decode error)";
  }
}

// 取引履歴の読み込み
export async function loadRecentTx() {
  if (!appState.NODE || !appState.currentAddress) return;

  const address = appState.currentAddress.toString();
  const url = new URL(
    `/transactions/confirmed?address=${address}&pageSize=10`,
    appState.NODE
  );

  try {
    const res = await fetch(url);
    const data = await res.json();

    const txList = data.data || [];
    const container = document.getElementById("tx-list");
    container.innerHTML = "";

    if (txList.length === 0) {
      container.innerHTML = "<p>取引履歴がありません</p>";
      return;
    }

    txList.forEach(tx => {
      const card = createTxCard(tx, "confirmed");
      container.appendChild(card);
    });

  } catch (err) {
    console.error("取引履歴取得エラー:", err);
  }
}

// トランザクションカードを作成
function createTxCard(tx, status) {
  const card = document.createElement("div");
  card.className = `tx-card ${status}`;

  const isTestnet = appState.networkType === NetworkType.TESTNET;
  const explorerBase = isTestnet
    ? "https://testnet.symbol.fyi"
    : "https://symbol.fyi";

  const hash = tx.meta?.hash || "(unknown)";
  const timestamp = formatTimestamp(tx.transaction?.timestamp);
  
  // 送信 or 受信の判定
  const signerAddress = tx.transaction?.signerPublicKey
    ? appState.facade.network.publicKeyToAddress(
        new appState.sdkCore.PublicKey(tx.transaction.signerPublicKey)
      ).toString()
    : "";

  const isSent = signerAddress === appState.currentAddress.toString();
  const direction = isSent ? "送信" : "受信";

  // 金額取得
  const mosaics = tx.transaction?.mosaics || [];
  let xymAmount = 0;
  if (mosaics.length > 0) {
    xymAmount = Number(mosaics[0].amount) / 1_000_000;
  }

  // メッセージ取得
  const message = decodeMessage(tx.transaction?.message);

  card.innerHTML = `
    <div class="tx-header">
      <span class="tx-direction ${direction === "送信" ? "sent" : "received"}">
        ${direction}
      </span>
      <span class="tx-status-badge ${status}">${status}</span>
    </div>
    <div class="tx-body">
      <p><strong>金額:</strong> ${xymAmount.toFixed(6)} XYM</p>
      <p><strong>時刻:</strong> ${timestamp}</p>
      <p><strong>メッセージ:</strong> ${message}</p>
      <p><strong>Hash:</strong> 
        <a href="${explorerBase}/transactions/${hash}" target="_blank">
          ${hash.substring(0, 16)}...
        </a>
      </p>
    </div>
  `;

  return card;
}

// リアルタイム更新の初期化
export function initLiveTx() {
  const address = appState.currentAddress.toString();

  // 未承認追加
  addCallback(`unconfirmedAdded/${address}`, (data) => {
    const tx = data.transaction;
    const card = createTxCard({ transaction: tx, meta: data.meta }, "unconfirmed");
    
    const container = document.getElementById("tx-list");
    container.insertBefore(card, container.firstChild);
  });

  // 承認済み追加
  addCallback(`confirmedAdded/${address}`, (data) => {
    const tx = data.transaction;
    const card = createTxCard({ transaction: tx, meta: data.meta }, "confirmed");

    // 既存の未承認カードを削除
    const hash = data.meta.hash;
    const oldCard = document.querySelector(`[data-hash="${hash}"]`);
    if (oldCard) {
      oldCard.remove();
    }

    const container = document.getElementById("tx-list");
    container.insertBefore(card, container.firstChild);
  });
}

🔍 このファイルのポイント

  1. formatTimestamp(): Symbol独自のタイムスタンプ→Unix時間
  2. decodeMessage(): Hexエンコードされたメッセージをデコード
  3. 送信/受信判定: signerPublicKeyから判別
  4. リアルタイム昇格: 未承認→承認の自動切り替え
  5. Explorerリンク: MainnetとTestnetで自動切り替え

9️⃣ ws.js — WebSocket(未承認/承認イベント)

Symbolの強力なリアルタイム機能を活用します。

// ws.js
import { appState } from "./config.js";
import { playSoundOnce } from "./utils.js";

let ws = null;
let uid = "";
let callbacks = {};
let soundHooksRegistered = false;

export function initWebSocket(address) {
  const wsUrl = appState.NODE.replace("http", "ws") + "/ws";
  ws = new WebSocket(wsUrl);

  ws.onopen = () => {
    console.log("WS Connected:", wsUrl);
    soundHooksRegistered = false;
  };

  ws.onmessage = e => {
    const data = JSON.parse(e.data);

    // ① 初回受信(uid 受け取り)
    if (data.uid !== undefined) {
      uid = data.uid;

      // 監視開始
      subscribe("block");
      subscribe(`unconfirmedAdded/${address}`);
      subscribe(`confirmedAdded/${address}`);

      // 音の callback は 1回だけ登録
      registerSoundCallbacks(address);
      return;
    }

    // ② イベント受信
    const topic = data.topic;
    const payload = data.data;

    if (callbacks[topic]) {
      callbacks[topic].forEach(cb => cb(payload));
    }
  };

  ws.onerror = err => {
    console.error("WebSocket error:", err);
  };

  ws.onclose = () => {
    console.log("WebSocket closed");
  };
}

function subscribe(channel) {
  if (!ws || !uid) return;
  ws.send(JSON.stringify({ uid, subscribe: channel }));
}

export function addCallback(topic, callback) {
  if (!callbacks[topic]) {
    callbacks[topic] = [];
  }
  callbacks[topic].push(callback);
}

function registerSoundCallbacks(address) {
  if (soundHooksRegistered) return;

  addCallback(`unconfirmedAdded/${address}`, () => {
    playSoundOnce("./sounds/notification.ogg");
  });

  addCallback(`confirmedAdded/${address}`, () => {
    playSoundOnce("./sounds/ding.ogg");
  });

  soundHooksRegistered = true;
}

🔍 このファイルのポイント

  1. WebSocket接続: ws://node/wsで接続
  2. subscribe: 特定のイベントを監視
  3. コールバック登録: 複数の処理を登録可能
  4. 音の二重登録防止: soundHooksRegisteredフラグ

🔟 utils.js — ポップアップ・サウンド

UX改善のための小さな部品集。

// utils.js
export function hexToBytes(hex) {
  const bytes = [];
  for (let c = 0; c < hex.length; c += 2)
    bytes.push(parseInt(hex.substr(c, 2), 16));
  return new Uint8Array(bytes);
}

// 2秒で自動消えるポップアップ表示
export function showPopup(message, isError = false) {
  let popup = document.getElementById("copy-popup");

  if (!popup) {
    popup = document.createElement("div");
    popup.id = "copy-popup";
    popup.className = "popup-card";
    popup.style.position = "fixed";
    popup.style.left = "50%";
    popup.style.top = "50%";
    popup.style.transform = "translate(-50%, -50%)";
    popup.style.zIndex = "9999";
    document.body.appendChild(popup);
  }

  popup.innerHTML = `<div>${message}</div>`;
  popup.style.display = "block";
  popup.style.opacity = "1";
  popup.style.transition = "opacity .4s";

  setTimeout(() => {
    popup.style.opacity = "0";
    setTimeout(() => {
      popup.style.display = "none";
    }, 400);
  }, 2000);
}

// 音の連続再生を防止
let soundQueue = [];
let isPlaying = false;

export function playSoundOnce(src) {
  soundQueue.push(src);
  if (!isPlaying) {
    playNext();
  }
}

function playNext() {
  if (soundQueue.length === 0) {
    isPlaying = false;
    return;
  }

  isPlaying = true;
  const src = soundQueue.shift();
  const audio = new Audio(src);
  
  audio.onended = () => {
    playNext();
  };

  audio.play().catch(err => {
    console.warn("音声再生失敗:", err);
    playNext();
  });
}

🎓 まとめ:この構成を理解すればウォレットを作れる

このウォレットは、 SymbolのWebクライアント開発で必要な要素がすべて詰まった"教材" です。

📝 このコードから学べること

ノード選択: NodeWatchを使った優良ノード自動選択
設定/状態管理: appStateによる一元管理
アカウント情報取得: REST APIの使い方
送金トランザクション: SDK + SSS署名の完全な流れ
WebSocketリアルタイム更新: 未承認→承認の昇格処理
SSS Extension連携: 秘密鍵を扱わない安全な署名

これらを理解すれば、あなた自身のdApp / Wallet / ツールを自由に構築できるようになります。


🔗 参考リンク


🚀 次のステップ

このウォレットをベースに、以下のような拡張が可能です:

  • マルチシグ対応
  • アグリゲートトランザクション
  • ネームスペース・モザイク管理
  • メタデータ機能

速習Symbol を学べばいろいろ出来るようになります。
資料をAIに投げれば、開発効率を上げる事もできます。

ぜひ自分のアイデアを加えて、オリジナルのSymbolアプリを作ってみてください!


質問・フィードバック大歓迎です!
コメント欄などでお気軽にどうぞ。

おまけ

SSS Extension 開発者の、いなたつさんがスマホ用の署名アプリを作ってくれたので、早速対応してみました。


以下のURLを、KeyStoneに登録して試してみてください。
(本記事のURLとは違うので注意⚠️)


https://mikunnem.github.io/Symbol_Simple_Wallet-KeyStone/

※こちらは、SSS Extension / KeyStone 両方に対応しています。

ここにURLを登録

IMG_2897.jpg


⚙️アカウント設定で、KeyStoneに秘密鍵を登録しておけば、 こんな感じでアカウント情報が表示されます。 XYMの送信も出来ます。ぜひ試してみてください。

IMG_2898.PNG

8
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
8
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?