セキュリティ上の理由から、実運用には適しません。
本記事の暗号化処理はJavaScriptによる実装であり、安全性が十分に担保されていないためです。
Expo 環境セットアップ
まずプロジェクトを作成しましょう。
npx create-expo-app@latest symbol-expo-sample --template blank-typescript
作成が完了したら、プロジェクトディレクトリへ移動します。
cd symbol-expo-sample
Web対応も行っておきましょう。
npx expo install react-dom react-native-web @expo/metro-runtime
そのまま起動して、動作確認を行いましょう。
npx expo start --tunnel
スマートフォンやブラウザで表示できることを確認してください。
Android:
iOS:
Symbol SDK 向けのカスタマイズ
Symbol-SDKにパッチを適用するため、patch-packageをインストールします。
npm i -D patch-package
package.json の scripts に postinstall を追加します。
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
- "web": "expo start --web"
+ "web": "expo start --web",
+ "postinstall": "patch-package"
},
patches ディレクトリを作成し、パッチファイル symbol-sdk+3.3.0.patch を配置します。
mkdir -p patches
cd patches
curl -OL https://github.com/nemnesia/symbol-sdk-non-node-patch/releases/download/v3.3.0/symbol-sdk+3.3.0.patch
cd -
Symbol SDKとポリフィルをインストールします。
npm i symbol-sdk @noble/hashes @noble/ciphers buffer assert stream readable-stream text-encoding react-native-url-polyfill
npx expo install expo-crypto @expo/metro-config
パッチが自動的に適用されなかった場合は、以下のコマンドを実行してください。
npm run postinstall
shim の追加
shims ディレクトリを作成し、以下の2ファイルを配置します。
import { getRandomBytes } from "expo-crypto";
import { Buffer } from "buffer";
if (!global.Buffer) {
global.Buffer = Buffer;
}
if (typeof global.process.browser === "undefined") {
global.process.browser = true;
}
if (!Uint8Array.prototype.copy) {
Uint8Array.prototype.copy = function (
target,
targetStart = 0,
sourceStart = 0,
sourceEnd = this.length
) {
target.set(this.subarray(sourceStart, sourceEnd), targetStart);
};
}
if (!global.window.crypto) {
global.window.crypto = {
getRandomValues: (arr) => {
const bytes = getRandomBytes(arr.length);
for (let i = 0; i < arr.length; i++) arr[i] = bytes[i];
return arr;
},
};
}
import { utf8ToBytes, bytesToHex } from "@noble/hashes/utils.js";
import { hmac as nobleHmac } from "@noble/hashes/hmac.js";
import { sha256, sha512 } from "@noble/hashes/sha2.js";
import { getRandomBytes } from "expo-crypto";
import { Buffer } from "buffer";
function concatMany(chunks) {
if (!chunks.length) return new Uint8Array(0);
let total = 0;
for (const c of chunks) total += c.length;
const out = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
out.set(c, off);
off += c.length;
}
return out;
}
module.exports = {
randomBytes: (size) => {
if (!Number.isInteger(size) || size < 1) {
throw new TypeError("randomBytes: size must be a positive integer");
}
if (size > 67108864) {
throw new RangeError("randomBytes: size too large (max 64MB)");
}
let bytes;
let retryCount = 0;
const maxRetries = 3;
while (retryCount < maxRetries) {
try {
bytes = getRandomBytes(size);
if (bytes.every((byte) => byte === 0)) {
throw new Error("PRNG failure: all zeros detected");
}
const uniqueBytes = new Set(bytes);
if (uniqueBytes.size < Math.min(size, 4)) {
throw new Error("PRNG failure: insufficient entropy detected");
}
break;
} catch (e) {
retryCount++;
if (retryCount >= maxRetries) {
throw new Error(
`randomBytes: failed to generate secure random bytes after ${maxRetries} attempts. ${e.message}`
);
}
continue;
}
}
if (!bytes || bytes.length !== size) {
throw new Error("randomBytes: generated bytes size mismatch");
}
const buffer = Buffer.from(bytes);
try {
bytes.fill(0);
if (bytes.buffer) {
new Uint8Array(bytes.buffer).fill(0);
}
} catch (e) {
// ignore cleanup errors
}
return buffer;
},
createHmac: (algorithm, key) => {
let algo;
if (algorithm === "sha256") {
algo = sha256;
} else if (algorithm === "sha512") {
algo = sha512;
} else {
throw new Error("Only sha256 and sha512 are supported in shim.");
}
const encoder = new TextEncoder();
const _key = typeof key === "string" ? encoder.encode(key) : key;
let _dataChunks = [];
const hmacInstance = {
update: (data) => {
const bytes = typeof data === "string" ? encoder.encode(data) : data;
_dataChunks.push(bytes);
return hmacInstance;
},
digest: (encoding) => {
const allData = concatMany(_dataChunks);
const mac = nobleHmac(algo, _key, allData);
if (encoding === "hex") {
return bytesToHex(mac);
}
return mac;
},
};
return hmacInstance;
},
createHash: (algo) => {
const hashFn =
algo === "sha256" ? sha256 : algo === "sha512" ? sha512 : null;
if (!hashFn) throw new Error(`Unsupported hash algorithm: ${algo}`);
let data = [];
const hashInstance = {
update: (chunk) => {
data.push(typeof chunk === "string" ? utf8ToBytes(chunk) : chunk);
return hashInstance;
},
digest: (encoding = "hex") => {
const all = concatMany(data);
const hashed = hashFn(all);
if (encoding === "hex") {
return bytesToHex(hashed);
}
return hashed;
},
};
return hashInstance;
},
};
アプリ起動時に ./shims/shim を読み込むようにします。
+ import "./shims/shim";
import { registerRootComponent } from "expo";
import App from "./App";
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);
Metro コンフィグの設定
プロジェクトのルートに metro.config.js を新規作成します。
const { getDefaultConfig } = require("@expo/metro-config");
const config = getDefaultConfig(__dirname);
config.resolver.extraNodeModules = {
...config.resolver.extraNodeModules,
crypto: require.resolve("./shims/crypto.js"),
url: require.resolve("react-native-url-polyfill"),
};
module.exports = config;
動作確認
以下の動作ができるか確認しましょう。
- ニーモニック生成
- アカウント生成
- トランザクション生成
import { StatusBar } from "expo-status-bar";
import { useEffect, useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import { Bip32 } from "symbol-sdk";
import {
descriptors,
generateMosaicAliasId,
models,
Network,
SymbolAccount,
SymbolFacade,
SymbolTransactionFactory,
} from "symbol-sdk/symbol";
export default function App() {
const [address, setAddress] = useState("");
const [publicKey, setPublicKey] = useState("");
const [txPayload, setTxPayload] = useState("");
useEffect(() => {
// facade生成
const facade = new SymbolFacade(Network.TESTNET);
const genAccount = (id: number): SymbolAccount => {
const bip32Path = facade.bip32Path(id); // Bip32Path生成
const childBip32Node = bip32Node.derivePath(bip32Path); // Bip32Pathから子Bip32Path生成
const keyPair = SymbolFacade.bip32NodeToKeyPair(childBip32Node); // 子Bip32Pathからキーペア生成
const account = new SymbolAccount(facade, keyPair); // キーペアからアカウント生成
console.log(`Account PrivateKey: ${account.keyPair.privateKey.toString()}`);
console.log(`Account PublicKey : ${account.publicKey.toString()}`);
console.log(`Account Address : ${account.address.toString()}`);
return account;
};
// ニーモニック生成
const bip32 = new Bip32();
const mnemonic = bip32.random();
console.log("Mnemonic:", mnemonic);
// Bip32Node生成
const passwd = "";
const bip32Node = bip32.fromMnemonic(mnemonic, passwd);
// アカウントを生成する
const account = genAccount(0);
setAddress(account.address.toString());
setPublicKey(account.publicKey.toString());
// _/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/
// _/ トランザクション生成
// _/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/
// 受信者アカウント生成
const recipientAccount = genAccount(1);
// 転送モザイク設定(ネームスペースからモザイクIDを解決)
const mosaicId = generateMosaicAliasId("symbol.xym");
const mosaics = [
new descriptors.UnresolvedMosaicDescriptor(
new models.UnresolvedMosaicId(mosaicId),
new models.Amount(10_000000n)
),
];
// 平文メッセージ
const message = new TextEncoder().encode("\0Hello, Symbol!!");
// 転送トランザクションディスクリプタ生成
const transferTxDescriptor =
new descriptors.TransferTransactionV1Descriptor(
recipientAccount.address, // 受信者アドレス
mosaics, // 転送モザイク
message // メッセージ
);
// 転送トランザクション生成
const transferTx = facade.createTransactionFromTypedDescriptor(
transferTxDescriptor, // トランザクションディスクリプタ
account.publicKey, // 送信者公開鍵
100, // 手数料係数
60 * 60 * 2 // 有効期限(秒)
);
// 署名
const sig = account.signTransaction(transferTx);
const payloadJsonString = SymbolTransactionFactory.attachSignature(
transferTx,
sig
);
setTxPayload(JSON.parse(payloadJsonString).payload);
console.log("TxPayload:", JSON.stringify(transferTx.toJson(), null, 2));
}, []);
return (
<View style={styles.container}>
<Text style={styles.label}>Address:</Text>
<Text style={styles.value}>{address}</Text>
<Text style={styles.label}>PublicKey:</Text>
<Text style={styles.value}>{publicKey}</Text>
<Text style={styles.label}>TxPayload:</Text>
<Text style={styles.value}>{txPayload}</Text>
<StatusBar style="auto" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#fff",
alignItems: "flex-start",
justifyContent: "flex-start",
padding: 16,
},
label: {
fontWeight: "bold",
marginTop: 8,
},
value: {
width: "100%",
flexWrap: "wrap",
color: "#333",
marginBottom: 4,
},
});
npx expo start --tunnel
アカウントアドレスとトランザクションペイロードが表示されていれば成功です。