はじめに
アドカレということもあり、誰が興味あるねん的な、需要が少なそうだけど実は結構面白いんじゃね?というテーマで書きたいと思います。
ブロックチェーンSymbolではsdkのバージョンの主流は2系で、ここ最近徐々にv3が浸透してきているような気がします。そこで色々と便利なv2じゃなくv3を使ったほうがいいのはなぜ?という点に焦点を当てて話を進めます。
たぶん、サイズだとか他にも色々理由はあると思うんだけど、僕が一番気に入ってるのは将来的な汎用性がとてつもなく高いこと。だと考えています。
Symbolはオープンソースで誰でもコピーチェーンを作ることができます。また、現在のメインネットでも新たなトランザクションをプラグインとして開発することで(各ノード運営者が受け入れるかどうかは別として)これまでにない新たな何かが生まれたりもします。例えばmonakaさんの記事のこれとか
ちょうど今日、ジャガーさんの記事でExtensionについて書かれてましたが、これは一切理解していないのでスルー。(おそらく名前からしても拡張できる何かなんだろう)
https://catnotes.xyz/symbol/extensions/what-is-an-extension?x
ここまではなんとなく知ってることかなと思います。
じゃー、それがv3と関係あるのか?
sdk v3の仕組み
まず、前提としてcatbufferというデータ構造が定義されたファイルがあります。
例えばこれ。一番よく使われるであろうTransferTransactionのデータ構造です。
import "transaction.cats"
# Shared content between TransferTransaction and EmbeddedTransferTransaction.
inline struct TransferTransactionBody
# recipient address
recipient_address = UnresolvedAddress
# size of attached message
message_size = uint16
# number of attached mosaics
mosaics_count = uint8
# reserved padding to align mosaics on 8-byte boundary
transfer_transaction_body_reserved_1 = make_reserved(uint8, 0)
# reserved padding to align mosaics on 8-byte boundary
transfer_transaction_body_reserved_2 = make_reserved(uint32, 0)
# attached mosaics
@sort_key(mosaic_id)
mosaics = array(UnresolvedMosaic, mosaics_count)
# attached message
message = array(uint8, message_size)
# Send mosaics and messages between two accounts (V1, latest).
struct TransferTransactionV1
TRANSACTION_VERSION = make_const(uint8, 1)
TRANSACTION_TYPE = make_const(TransactionType, TRANSFER)
inline Transaction
inline TransferTransactionBody
# Embedded version of TransferTransaction (V1, latest).
struct EmbeddedTransferTransactionV1
TRANSACTION_VERSION = make_const(uint8, 1)
TRANSACTION_TYPE = make_const(TransactionType, TRANSFER)
inline EmbeddedTransaction
inline TransferTransactionBody
完全に理解する必要はありませんが、
recipient_address = UnresolvedAddress
///
mosaics = array(UnresolvedMosaic, mosaics_count)
///
message = array(uint8, message_size)
このへんは見覚えがあるかと。まぁ要するにTransferTransactionV1というクラスにはこんなプロパティがあるんだよ、と定義しているファイルです。このファイルが各トランザクションやそのトランザクション内で使用されているクラスの数だけ存在します。
そして、これをgeneratorを使うと、以下のようなクラスが生成されます。(全部貼り付けたけどスルーで良い)
export class TransferTransactionV1 {
static TRANSACTION_VERSION = 1;
static TRANSACTION_TYPE = TransactionType.TRANSFER;
static TYPE_HINTS = {
signature: 'pod:Signature',
signerPublicKey: 'pod:PublicKey',
network: 'enum:NetworkType',
type: 'enum:TransactionType',
fee: 'pod:Amount',
deadline: 'pod:Timestamp',
recipientAddress: 'pod:UnresolvedAddress',
mosaics: 'array[UnresolvedMosaic]',
message: 'bytes_array'
};
constructor() {
this._signature = new Signature();
this._signerPublicKey = new PublicKey();
this._version = TransferTransactionV1.TRANSACTION_VERSION;
this._network = NetworkType.MAINNET;
this._type = TransferTransactionV1.TRANSACTION_TYPE;
this._fee = new Amount();
this._deadline = new Timestamp();
this._recipientAddress = new UnresolvedAddress();
this._mosaics = [];
this._message = new Uint8Array();
this._verifiableEntityHeaderReserved_1 = 0; // reserved field
this._entityBodyReserved_1 = 0; // reserved field
this._transferTransactionBodyReserved_1 = 0; // reserved field
this._transferTransactionBodyReserved_2 = 0; // reserved field
}
sort() {
this._mosaics = this._mosaics.sort((lhs, rhs) => arrayHelpers.deepCompare(
(lhs.mosaicId.comparer ? lhs.mosaicId.comparer() : lhs.mosaicId.value),
(rhs.mosaicId.comparer ? rhs.mosaicId.comparer() : rhs.mosaicId.value)
));
}
get signature() {
return this._signature;
}
set signature(value) {
this._signature = value;
}
get signerPublicKey() {
return this._signerPublicKey;
}
set signerPublicKey(value) {
this._signerPublicKey = value;
}
get version() {
return this._version;
}
set version(value) {
this._version = value;
}
get network() {
return this._network;
}
set network(value) {
this._network = value;
}
get type() {
return this._type;
}
set type(value) {
this._type = value;
}
get fee() {
return this._fee;
}
set fee(value) {
this._fee = value;
}
get deadline() {
return this._deadline;
}
set deadline(value) {
this._deadline = value;
}
get recipientAddress() {
return this._recipientAddress;
}
set recipientAddress(value) {
this._recipientAddress = value;
}
get mosaics() {
return this._mosaics;
}
set mosaics(value) {
this._mosaics = value;
}
get message() {
return this._message;
}
set message(value) {
this._message = value;
}
get size() { // eslint-disable-line class-methods-use-this
let size = 0;
size += 4;
size += 4;
size += this.signature.size;
size += this.signerPublicKey.size;
size += 4;
size += 1;
size += this.network.size;
size += this.type.size;
size += this.fee.size;
size += this.deadline.size;
size += this.recipientAddress.size;
size += 2;
size += 1;
size += 1;
size += 4;
size += arrayHelpers.size(this.mosaics);
size += this._message.length;
return size;
}
static deserialize(payload) {
const view = new BufferView(payload);
const size = converter.bytesToInt(view.buffer, 4, false);
view.shiftRight(4);
view.shrink(size - 4);
const verifiableEntityHeaderReserved_1 = converter.bytesToInt(view.buffer, 4, false);
view.shiftRight(4);
if (0 !== verifiableEntityHeaderReserved_1)
throw RangeError(`Invalid value of reserved field (${verifiableEntityHeaderReserved_1})`);
const signature = Signature.deserialize(view.buffer);
view.shiftRight(signature.size);
const signerPublicKey = PublicKey.deserialize(view.buffer);
view.shiftRight(signerPublicKey.size);
const entityBodyReserved_1 = converter.bytesToInt(view.buffer, 4, false);
view.shiftRight(4);
if (0 !== entityBodyReserved_1)
throw RangeError(`Invalid value of reserved field (${entityBodyReserved_1})`);
const version = converter.bytesToInt(view.buffer, 1, false);
view.shiftRight(1);
const network = NetworkType.deserializeAligned(view.buffer);
view.shiftRight(network.size);
const type = TransactionType.deserializeAligned(view.buffer);
view.shiftRight(type.size);
const fee = Amount.deserializeAligned(view.buffer);
view.shiftRight(fee.size);
const deadline = Timestamp.deserializeAligned(view.buffer);
view.shiftRight(deadline.size);
const recipientAddress = UnresolvedAddress.deserialize(view.buffer);
view.shiftRight(recipientAddress.size);
const messageSize = converter.bytesToInt(view.buffer, 2, false);
view.shiftRight(2);
const mosaicsCount = converter.bytesToInt(view.buffer, 1, false);
view.shiftRight(1);
const transferTransactionBodyReserved_1 = converter.bytesToInt(view.buffer, 1, false);
view.shiftRight(1);
if (0 !== transferTransactionBodyReserved_1)
throw RangeError(`Invalid value of reserved field (${transferTransactionBodyReserved_1})`);
const transferTransactionBodyReserved_2 = converter.bytesToInt(view.buffer, 4, false);
view.shiftRight(4);
if (0 !== transferTransactionBodyReserved_2)
throw RangeError(`Invalid value of reserved field (${transferTransactionBodyReserved_2})`);
const mosaics = arrayHelpers.readArrayCount(view.buffer, UnresolvedMosaic, mosaicsCount, e => ((e.mosaicId.comparer ? e.mosaicId.comparer() : e.mosaicId.value)));
view.shiftRight(arrayHelpers.size(mosaics));
const message = new Uint8Array(view.buffer.buffer, view.buffer.byteOffset, messageSize);
view.shiftRight(messageSize);
const instance = new TransferTransactionV1();
instance._signature = signature;
instance._signerPublicKey = signerPublicKey;
instance._version = version;
instance._network = network;
instance._type = type;
instance._fee = fee;
instance._deadline = deadline;
instance._recipientAddress = recipientAddress;
instance._mosaics = mosaics;
instance._message = message;
return instance;
}
serialize() {
const buffer = new Writer(this.size);
buffer.write(converter.intToBytes(this.size, 4, false));
buffer.write(converter.intToBytes(this._verifiableEntityHeaderReserved_1, 4, false));
buffer.write(this._signature.serialize());
buffer.write(this._signerPublicKey.serialize());
buffer.write(converter.intToBytes(this._entityBodyReserved_1, 4, false));
buffer.write(converter.intToBytes(this._version, 1, false));
buffer.write(this._network.serialize());
buffer.write(this._type.serialize());
buffer.write(this._fee.serialize());
buffer.write(this._deadline.serialize());
buffer.write(this._recipientAddress.serialize());
buffer.write(converter.intToBytes(this._message.length, 2, false)); // bound: message_size
buffer.write(converter.intToBytes(this._mosaics.length, 1, false)); // bound: mosaics_count
buffer.write(converter.intToBytes(this._transferTransactionBodyReserved_1, 1, false));
buffer.write(converter.intToBytes(this._transferTransactionBodyReserved_2, 4, false));
arrayHelpers.writeArray(buffer, this._mosaics, e => ((e.mosaicId.comparer ? e.mosaicId.comparer() : e.mosaicId.value)));
buffer.write(this._message);
return buffer.storage;
}
toString() {
let result = '(';
result += `signature: ${this._signature.toString()}, `;
result += `signerPublicKey: ${this._signerPublicKey.toString()}, `;
result += `version: ${'0x'.concat(this._version.toString(16))}, `;
result += `network: ${this._network.toString()}, `;
result += `type: ${this._type.toString()}, `;
result += `fee: ${this._fee.toString()}, `;
result += `deadline: ${this._deadline.toString()}, `;
result += `recipientAddress: ${this._recipientAddress.toString()}, `;
result += `mosaics: [${this._mosaics.map(e => e.toString()).join(',')}], `;
result += `message: hex(${converter.uint8ToHex(this._message)}), `;
result += ')';
return result;
}
}
そして、実はこのクラスさえあればトランザクションを作成して、アナウンスすることができます。もちろんここで使われているSignature
やPublicKey
などのクラスも同時に生成されています。
それがjavascriptならこのファイル
これはcatbufferを元にgeneratorで生成されたクラス群のファイルです。
sdk v3 って何?というとこのコアなクラスが定義されているファイルだと僕は思ってて、じゃあ他の例えばfacadeとかは何かと言うと、それらを便利に活用するために追加されたモノ達かなと(※個人の感想です)
は?これだけでトランザクション作れるとかふざけるな?ともし思われたとしたらそれは大きな間違いです。この記事の最後に実証します。
※追記
正確にはmodels.jsだけでは無理だった。。。とは言え少し追加するだけには変わりない
ポイント1
このクラス群さえあれば、トランザクションは生成できる。と考えると多言語のsdkも思った以上に作りやすいとは思いませんか?(いや、もちろん簡単ではないんですけど。
generatorはpythonで書かれていて、このcatbufferを元に各クラスが出力されるような仕組みです。ちょっと中身は説明しがたいのですが、特にその言語に精通しているなら作成できるはずです。
各言語SDKの作成が容易
ポイント2
さらには、新しくプラグインによってトランザクションが生み出された際に、先程のTransferTransactionのcatbufferのようなファイルを一つ作るだけでgeneratorに流し込めばsdkのバージョンアップは完了です。
新たなプラグイントランザクションへの対応が容易
ちなみに余談ですが、プラグイントランザクションをカタパルト側で作成するときのモデルもこのcatbufferに似ているのですが、いずれcatbufferを元に自動生成できるようになればいいな的なことがどこかのドキュメントに書かれていた。
つまり、、まとめると
新しいプラグイントランザクションは誰でも作成可能で、さらにそれを開発者が容易に使うためのsdkも簡単に生成できるということ。このあたり、コアデブの思想が見え隠れするような気がしませんか?
自分たちが離れても自走するようなチェーン、エコシステムの完成を見ているんじゃないかなと(※個人の感想です)
社会実装が少しずつではありますが始まったり、コミュニティレベルで自走しつつあるSymbol。めちゃめちゃ面白いですよね。もちろんまだまだコアデブ頼りな部分はあるけど、そういう思想があるんだろうなーと思えば、僕のイメージはコアデブは社長じゃなくて先輩的な感じ。困ったら頼りたいけど、おんぶに抱っこは違うよね。
なんか話がそれたけど、ぜひ sdk v3 の導入を進めて、未来のSymbolに準備していくといいんじゃないかなとこの記事を終えます。
※この後に、modelだけでトランザクションを作成&アナウンスは書きます
モデルだけでトランザクション!
javascriptで書きます、もちろんpythonやc#など現存するsdkでも可能です。詳細はコメントで書いておきます。
必要なライブラリのインストール
npm i tweetnacl node-fetch js-sha3
// models.jsから使用するクラスだけインポート
import {
Timestamp,
TransferTransactionV1,
PublicKey,
NetworkType,
UnresolvedAddress,
UnresolvedMosaic,
UnresolvedMosaicId,
Amount,
Signature } from 'symbol-sdk/src/symbol/models.js';
// 署名用ライブラリ
import nacl from 'tweetnacl';
// アナウンス用、何でも良い
import fetch from 'node-fetch';
// ハッシュ作成用、ハッシュ不要ならなくてもいい
import jsSha3 from 'js-sha3';
const sha3_256 = jsSha3.sha3_256;
// テストネットのgeneration hash をbyte[]にする
const generationHash = new Uint8Array(hexToBytes("49D6E1CE276A85B70EAFE52349AACCA389302E7A9754BCF1221E79494FC665A4"));
// 秘密鍵
const privateKey = new Uint8Array(hexToBytes("5DB8324E7EB83E7665D500B014283260EF312139034E86DFB7EE736503******"));
// 秘密鍵を元にkeypairの作成
const keyPair = nacl.sign.keyPair.fromSeed(privateKey);
// トランザクション作成
const tx = new TransferTransactionV1();
tx.signerPublicKey = new PublicKey(keyPair.publicKey);
tx.network = NetworkType.TESTNET;
// 現在時刻からepocを引いて2時間追加
tx.deadline = new Timestamp(BigInt(Date.now() - 1667250467/* epocAdjustment */ * 1000 + 7200 * 1000 /* 2 hours */));
// NやTから始まるアドレスの場合はデコードが面倒なので今回は16進数で。RESTから取得できる
tx.recipientAddress = new UnresolvedAddress(hexToBytes("983AB360969797AB6030FF53A1995F43B27C56C5B456E2D9"));
const mosaic = new UnresolvedMosaic();
// UnresolvedMosaicIdやAmountの引数はBigInt
mosaic.mosaicId = new UnresolvedMosaicId(0x72C0212E67A08BCEn);
mosaic.amount = new Amount(100n);
tx.mosaics = [mosaic];
// messageもbyte[]ですが暗黙の了解で頭の1byteは1にする
tx.message = new Uint8Array([1, ...new TextEncoder().encode("hello, symbol!!")]);
// 手数料はサイズ * 100にしておく
tx.fee = new Amount(BigInt(tx.size * 100));
// generation hash + トランザクションボディに対してED25519署名
const sign = nacl.sign.detached(new Uint8Array([...generationHash, ...tx.serialize().slice(8 + 64 + 32 + 4)]), keyPair.secretKey);
// 署名をトランザクションに追加
tx.signature = new Signature(sign);
// トランザクションステータス確認用にハッシュ作成
const hasher = sha3_256.create();
hasher.update(tx.signature.bytes);
hasher.update(tx.signerPublicKey.bytes);
hasher.update(generationHash);
hasher.update(tx.serialize().slice(8 + 64 + 32 + 4));
const hash = new Uint8Array(hasher.arrayBuffer());
console.log("hash: ", byteToHex(hash));
// ノードにアナウンス
fetch("https://NODEURL:3001/transactions", {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ payload: byteToHex(tx.serialize()) }),
}).then(res => res.json()).then(console.log);
function hexToBytes(hex) {
const bytes = new Uint8Array(Math.ceil(hex.length / 2));
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
}
function byteToHex(byteArray) {
return Array.from(byteArray, byte => ('0' + (byte & 0xFF).toString(16)).slice(-2)).join('').toUpperCase();
}
modelだけでアナウンスできました!
※追記
正確にはmodels.jsだけでは無理でした
例えばSignatureクラスはByteArrayを継承しているのでそこは必要。とは言えこの程度。
export default class ByteArray {
/**
* Creates a byte array.
* @param {number} fixedSize Size of the array.
* @param {Uint8Array|string} arrayInput Byte array or hex string.
*/
constructor(fixedSize, arrayInput) {
let rawBytes = arrayInput;
if ('string' === typeof rawBytes)
rawBytes = hexToUint8(rawBytes);
if (fixedSize !== rawBytes.length)
throw RangeError(`bytes was size ${rawBytes.length} but must be ${fixedSize}`);
/**
* Underlying bytes.
* @type Uint8Array
*/
this.bytes = new Uint8Array(rawBytes);
}
/**
* Returns string representation of this object.
* @returns {string} String representation of this object
*/
toString() {
return uint8ToHex(this.bytes);
}
}
これもmodelsに出力すればいいんじゃね?とも思うけど、実はこのsdk、symbolだけじゃなくnemにも対応してて、ByteArrayやBaseValueはそちらでも使うのでディレクトリの階層が違う。