この記事を読めば、自分で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
}
🔍 このファイルのポイント
- NodeWatch URL: 優良ノードを自動取得するAPIエンドポイント
- Fallback Nodes: NodeWatchが落ちていても接続できるバックアップ
- appState: アプリ全体で使う「グローバル変数」を1箇所で管理
- 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();
});
🔍 初期化の流れ
💡 なぜこの順番?
- SSS接続が最優先: ユーザーのアカウント情報がないと何もできない
- ネットワーク判定→ノード選択: MainnetかTestnetかでノードが変わる
- SDK初期化: トランザクション作成に必要
- 残高・履歴取得: ユーザーに情報を表示
- 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;
}
}
🔍 このファイルのポイント
- NodeWatch API: 優良ノードリストを取得
- ブロック高で判定: 最も同期が進んでいるノードを選択
- タイムアウト処理: 1.5秒で諦めてFallbackへ
- 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;
}
🔍 このファイルのポイント
- 動的import: CDNからSDKを読み込み(npm不要)
- epochAdjustment: Symbolのタイムスタンプ→Unix時間変換に必須
- Facade: トランザクション作成・署名の中心クラス
- 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();
}
🔍 このファイルのポイント
- window.SSS: Chrome拡張から提供されるグローバルオブジェクト
- activePublicKey(): 現在選択中のアカウントの公開鍵を取得
- activeNetworkType(): MainnetかTestnetかを判定
- ネットワークロック: 途中でTestnet↔Mainnetを切り替えられないようにする
- 自動初期化: 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");
}
}
🔍 このファイルのポイント
- GET /accounts/{address}: REST APIでアカウント情報取得
- mosaics配列: Symbolでは複数のトークンを所持可能
- XYMの判別: ネットワークごとに異なるMosaic IDで判定
- 可分性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");
}
}
🔍 トランザクション作成の流れ
🔍 このファイルのポイント
- transactionFactory.create(): SDKでトランザクション作成
-
手数料計算:
tx.size * 100で安全マージン -
SSS署名:
window.SSS.setTransactionByPayload()→requestSign() - 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);
});
}
🔍 このファイルのポイント
- formatTimestamp(): Symbol独自のタイムスタンプ→Unix時間
- decodeMessage(): Hexエンコードされたメッセージをデコード
- 送信/受信判定: signerPublicKeyから判別
- リアルタイム昇格: 未承認→承認の自動切り替え
- 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;
}
🔍 このファイルのポイント
-
WebSocket接続:
ws://node/wsで接続 - subscribe: 特定のイベントを監視
- コールバック登録: 複数の処理を登録可能
-
音の二重登録防止:
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を登録
⚙️アカウント設定で、KeyStoneに秘密鍵を登録しておけば、 こんな感じでアカウント情報が表示されます。 XYMの送信も出来ます。ぜひ試してみてください。


