Symbolブロックチェーンはプログラミング言語を問わずスマートコントラクトが実装できるように設計されています。ところが、現在リリースされているsdkアルファ版はJavascriptとPythonのみサポートです。では、他の言語ではまだアプリ開発できないのでしょうか?と言われればそうでもありません。
組み込みされたデジタル装置で署名したかったり、マルチシグ化した連署者アカウントの署名をサーバ側で機械的に実施したり、IoTなどほぼ単一のトランザクションを定期的に実施したり、複雑なことは必要ないユースケースが実はたくさん眠っているかもしれません。
ここでは、そういった「あとはSymbolブロックチェーンにトランザクション投げるだけなんだが」といった方を対象にトランザクションの作り方を解説します。
なお、トランザクションの送信以外(例えばモザイクの生成やマルチシグの組成など)はウォレットなどを利用して準備する前提で進めます。
後記:
Symbol/NEMのコアチームからのSDKについての考え方を転載しておきます。
私たちは、コミュニティが(SDKではなく)直接RESTを使用することを望んでいます。暗号通貨SDKのデザインパターンは、ウォレットの機能とアドレスなどを扱うだけでよいというものです。私たちはOpenAPIライブラリを書くこともできますが、代わりにコミュニティがこれらのライブラリやツールを作ってくれることを望んでいます。
これらのデザインパターンは、ユーザーや新規開発者がSymbol 2.0で複数のSubChainを扱う際に遭遇するUXに備えることを目的としています。
コミュニティで開発されたTypeScript SDKがあると良いですね。Python SDKを参考にしながら、コミュニティがたくさんのSDKを開発することを期待します。
検証済みの言語を紹介しておきます。
最低限必要な機能
どんな言語でも実装できるとはいえ、これだけは必要と思われる機能について確認しておきます。
- HTTPアクセス
- プログラムからノードのエンドポイントにHTTPでアクセスする必要があります。必須ではありませんがWebSocketにも対応しているとトランザクションの承認状態等の監視が出来て便利です(今回は説明しません)
- 署名
- Ed25519の署名をサポートしたライブラリ(NaClやSodium)が必要です。古い言語はここが難しいかもしれません。
- BigInt
- SymbolブロックチェーンではIntegerで定義される数字よりも大きな数字を頻繁に扱います。最後には文字列にするので必ずしも必要ありませんが、トランザクションの有効期限などを現在時刻から加算計算する場合に数値として扱えると便利です。
トランザクションの送信先
現在、世界中に1200か所ほど分散して存在しているSymbol APIノードのエンドポイントに接続します。
以下のサイトから"https"対応と表示されたノードを選択して、署名されたトランザクションを送信します。
https://symbolnodes.org/nodes/
実際にpayloadをjson形式でPUTするエンドポイント /transactions
の仕様については以下のAPIリファレンスを参照ください。
https://symbol.github.io/symbol-openapi/v1.0.3/#operation/announceTransaction
トランザクションの送信方法
上記エンドポイントに送信するデータはこんなフォーマットになっています。
payload = '{"payload":"A000000000000000934A1630CD8749708872E11229033C6A78BD3CFC53E9C62223875427A950390F01E9F7D4BC3480F06BA4777508A59BB72C3ECE0963E238DB9BE4AD823534B90F0E5C72B0D5946C1EFEE7E5317C5985F106B739BB0BC07E4F9A288417B3CD6D260000000001985441803E000000000000B4652371030000009850BF0FD1A45FCEE211B57D0FE2B6421EB81979814F62920000000000000000"}'
JavaScriptで送信する場合以下のような書き方になります。
response = await fetch(`${node}/transactions`, {
method: 'put',
body: payload,
headers: {'Content-Type': 'application/json'}
});
await response.json();
トランザクションのデータ構成
以下のpayloadの構成を調べてみます。
BA0000000000000073F13B93649BF187DA16500B3EB5EE4ADD58CF88F20A8FC9061A00F4ADEB04B8C67FEE785BB832A0D4A28D28FF940B1189CD231875A6420822C16AF37AF68F000E5C72B0D5946C1EFEE7E5317C5985F106B739BB0BC07E4F9A288417B3CD6D260000000001985441A848000000000000709CE07D0300000098F96BD2F803DE1EE39AACFC53A246F4F7A46901A5D0A53E0A00010000000000C8B6532DDB16843A40420F000000000000476F6F644C75636B21
16文字ごとに改行すると以下のような構成を取っているのがわかります。
BA00000000000000 //size reserved1
73F13B93649BF187 //signature
DA16500B3EB5EE4A
DD58CF88F20A8FC9
061A00F4ADEB04B8
C67FEE785BB832A0
D4A28D28FF940B11
89CD231875A64208
22C16AF37AF68F00
0E5C72B0D5946C1E //signer
FEE7E5317C5985F1
06B739BB0BC07E4F
9A288417B3CD6D26
0000000001985441 //reserved2 version networkType txType
A848000000000000 //deadline
709CE07D03000000 //fee
98F96BD2F803DE1E //recipientAddress
E39AACFC53A246F4
F7A46901A5D0A53E
0A00010000000000 //messageSize mosaicCount txReserved
C8B6532DDB16843A //mosaicId
40420F0000000000 //mosaicAmount
00476F6F644C7563 //message
6B21
トランザクションのデータ構成仕様
catbuffer/schemas と呼ばれるレポジトリで定義されています。
SDKはこのスキーマを参考にして自動生成されています。
今回解説する転送トランザクションTRANSFER
のアウトラインは以下のファイルで定義されています。
transfer/transfer.cats
https://github.com/symbol/symbol/blob/dev/catbuffer/schemas/symbol/transfer/transfer.cats
struct TransferTransaction
TRANSACTION_VERSION = make_const(uint8, 1)
TRANSACTION_TYPE = make_const(TransactionType, TRANSFER)
inline Transaction
inline TransferTransactionBody
inlineで列記された Transaction
とTransferTransactionBody
で構成されることが分かります。
TransferTransactionBody
については同じファイルに記載されています。
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)
よく分からない型が出てきたらここに記載されていないか調べてみましょう。
https://github.com/symbol/symbol/blob/dev/catbuffer/schemas/symbol/types.cats
次にTransaction
が記載されている以下のファイルを見てみます。
transaction.cats
https://github.com/symbol/symbol/blob/dev/catbuffer/schemas/symbol/transaction.cats
トランザクション全般の構成が記載されています。
abstract struct Transaction
inline SizePrefixedEntity
inline VerifiableEntity
inline EntityBody
# transaction type
type = TransactionType
# transaction fee
fee = Amount
# transaction deadline
deadline = Timestamp
SizePrefixedEntity
,VerifiableEntity
,EntityBody
については以下のファイルに構成が記載されています。
entity.cats
https://github.com/symbol/symbol/blob/dev/catbuffer/schemas/symbol/entity.cats
このようにして、トランザクションに必要なデータやその構成をcatbufferで知ることができます。
結果として転送トランザクションの構成は以下の通りとなります。
inline Transaction
inline SizePrefixedEntity
size = uint32
inline VerifiableEntity
verifiable_entity_header_reserved_1 = make_reserved(uint32, 0)
signature = Signature
using Signature = binary_fixed(64)
inline EntityBody
signer_public_key = PublicKey
using PublicKey = binary_fixed(32)
entity_body_reserved_1 = make_reserved(uint32, 0)
version = uint8
network = NetworkType
enum NetworkType : uint8
type = TransactionType
enum TransactionType : uint16
fee = Amount
using Amount = uint64
deadline = Timestamp
using Timestamp = uint64
inline TransferTransactionBody
recipient_address = UnresolvedAddress
using UnresolvedAddress = binary_fixed(24)
message_size = uint16
mosaics_count = uint8
transfer_transaction_body_reserved_1 = make_reserved(uint8, 0)
transfer_transaction_body_reserved_2 = make_reserved(uint32, 0)
@sort_key(mosaic_id)
mosaics = array(UnresolvedMosaic, mosaics_count)
mosaic_id = UnresolvedMosaicId
using UnresolvedMosaicId = uint64
amount = Amount
using Amount = uint64
message = array(uint8, message_size)
トランザクションデータ作成
では実際にトランザクションデータを作成していきましょう。
txSize = Buffer.from(new Uint32Array([0]).buffer); //トランザクションの長さ
reserved1 = Buffer.from(new Uint32Array([0]).buffer); //0固定
signature = Buffer.from('00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000','hex');
signer = Buffer.from('0e5c72b0d5946c1efee7e5317c5985f106b739bb0bc07e4f9a288417b3cd6d26','hex'); //署名者の公開鍵
reserved2 = Buffer.from(new Uint32Array([0]).buffer); //0固定
version = Buffer.from(new Uint8Array([1]).buffer); //現在1固定
networkType = Buffer.from(new Uint8Array([152]).buffer); //testnet 152固定
txType = Buffer.from(new Uint16Array([16724]).buffer); //転送トランザクションは16724固定
fee = Buffer.from(new BigInt64Array([16000n]).buffer); //最大手数料
deadline = Buffer.from(new BigInt64Array([14834924187n]).buffer); //有効期限
recipientAddress = Buffer.from(base32.decode("TBIL6D6RURP45YQRWV6Q7YVWIIPLQGLZQFHWFEQ" + "A").slice(0, -1)); //送信先アドレス
messageSize = Buffer.from(new Uint16Array([14]).buffer); //メッセージの長さ
mosaicCount = Buffer.from(new Uint8Array([1]).buffer); //送信するモザイクの種類
txReserved = [...Buffer.from(new Uint8Array([0]).buffer),...Buffer.from(new Uint32Array([0]).buffer)]; //0固定
mosaicId = Buffer.from(new BigInt64Array([BigInt("0x3A8416DB2D53B6C8")]).buffer); //モザイクID
mosaicAmount = Buffer.from(new BigInt64Array([100n]).buffer); //モザイク数量
message = Buffer.from([0,...(new TextEncoder('utf-8')).encode('Hello Symbol!')]); //メッセージ
deadline
Symbolでは初めてブロックが生成された時点を基準にネットワーク時計が存在しています。
deadlineもその基準に合わせて指定する必要があります。
epochAdjustment = 1637848847; //テストネットの誕生時刻 Thu Nov 25 2021 23:00:47 GMT+0900 (日本標準時)
secs = 7200; //2時間後を期限とする場合
value = ((Math.trunc(Date.now() / 1000) + secs) - epochAdjustment) * 1000;
deadline = BigInt(value);
2023年7月現在、テストネットのepochAdjustment値は以下のとおりです。
1667250467
リトルエンディアン
数値はすべてリトルエンディアン(桁数の小さいものを左に)指定してください。
MosaicIdも内部ではBigIntで保持しているのでリトルエンディアンで指定する必要があります。
recipientAddress
アドレスはBase32エンコードされているのでdecodeする必要があります。
Base32の仕様上、アドレスの最後に"A"を付けてデコード後の配列から末尾を除去してください。
言語によってはそのような作業が必要ないライブラリが提供されている場合もあります。
recipientAddress = Buffer.from(base32.decode("TBIL6D6RURP45YQRWV6Q7YVWIIPLQGLZQFHWFEQ" + "A").slice(0, -1)); //送信先アドレス
以下のように署名する部分のみ結合します。
verifiableData = [
...version,
...networkType,
...txType,
...fee,
...deadline,
...recipientAddress,
...messageSize,
...mosaicCount,
...txReserved,
...mosaicId,
...mosaicAmount,
...message
];
署名
作成したトランザクションを署名します。
この部分は各言語によって書き方が大きく異なると思います。
ぜひ得意言語のライブラリからEd25519の署名を探してみてください。
signature = keyPair.sign(new Uint8Array([
...Buffer.from("7fccd304802016bebbcd342a332f91ff1f3bb5e902988b352697be245f48e836","hex"),
...verifiableData
]));
generationHashと呼ばれる最初にネットワークを作るときに定義したシード値をトランザクションの検証部分に付与して署名します。
testnetは7fccd304802016bebbcd342a332f91ff1f3bb5e902988b352697be245f48e836
です。
2023年7月現在、テストネットのgenerationHash値は以下のとおりです。
49D6E1CE276A85B70EAFE52349AACCA389302E7A9754BCF1221E79494FC665A4
検算
上記verifiableDataを下記で作成したkeypairで署名した場合の出力を記載しておきます。
Buffer.from([
...Buffer.from("7fccd304802016bebbcd342a332f91ff1f3bb5e902988b352697be245f48e836","hex"),
...verifiableData
]).toString("hex");
署名対象となるペイロード
> 7fccd304802016bebbcd342a332f91ff1f3bb5e902988b352697be245f48e83601985441803e0000000000009bfa3a74030000009850bf0fd1a45fcee211b57d0fe2b6421eb81979814f62920e00010000000000c8b6532ddb16843a64000000000000000048656c6c6f2053796d626f6c21
sym.KeyPair.createKeyPairFromPrivateKeyString("94ee0f4d7fe388ac4b04a6a6ae2ba969617879b83616e4d25710d688a89d80c7");
signature = sym.KeyPair.sign(keypair,new Uint8Array([
...Buffer.from("7fccd304802016bebbcd342a332f91ff1f3bb5e902988b352697be245f48e836","hex"),
...verifiableData
]));
署名結果
Buffer.from(signature).toString("hex");
> '86221d79b2c63cb5385ff4a90820f69b28a8759d99ea39f70c168ef1e66174cce3e99862f0ca71a253b4c7b697b0dfa751d63937f07c62176664c5f36ce59b0a'
jsonフォーマットでpayloadを作成
txSize = Buffer.from(new Uint32Array([verifiableData.length + 108]).buffer);
txBuffer = [
...txSize,
...reserved1,
...signature,
...signer,
...reserved2,
...verifiableData
]
hexPayload = Buffer.from(txBuffer).toString('hex');
payload = `{"payload": "${hexPayload}"}`;
トランザクション送信
以下のコマンドでトランザクションを送信します。
response = await fetch(`${node}/transactions`, {
method: 'put',
body: payload,
headers: {'Content-Type': 'application/json'}
})
await response.text()
payloadがノードに受理されればstatus:200が返ってきますが、これはブロックチェーンに承認されたことを意味するものではありません。トランザクションが妥当であった場合のみ未承認トランザクションとしてブロックチェーンの検証対象となります。
トランザクションの確認
送信したトランザクションの行方はhash値で問い合わせることができます。
hash値の生成方法について説明します。
hasher = sha3_256.create();
hasher.update(signature);
hasher.update(signer);
hasher.update(Buffer.from("7fccd304802016bebbcd342a332f91ff1f3bb5e902988b352697be245f48e836","hex"));
hasher.update(verifiableData);
txHash = hasher.hex();
//承認状態の確認
res = await fetch(`${node}/transactionStatus/${txHash}`);
console.log(await res.json());
//承認済みトランザクションの確認
res = await fetch(`${node}/transactions/confirmed/${txHash}`);
console.log(await res.json());
//エクスプローラーで確認
console.log(`https://testnet.symbol.fyi/transactions/${txHash}`);
さいごに
今回はSymbolブロックチェーンの最も単純な送金についてのみ解説しました。
Symbolブロックチェーンのアグリゲートトランザクションを使えば、一連の送受信をまとめて処理したり署名がそろうまでトランザクションの処理をロックしたり様々なスマートコントラクトが記述できるようになります。
また後日紹介するかもしれませんが、待てないという方はぜひCatbufferを調べてみてください。
追記:書きました。
Appendex
base32.decode
charMapping = {
createBuilder: () => {
const map = {};
return {
map,
addRange: (start, end, base) => {
const startCode = start.charCodeAt(0);
const endCode = end.charCodeAt(0);
for (let code = startCode; code <= endCode; ++code)
map[String.fromCharCode(code)] = code - startCode + base;
}
};
}
};
//https://github.com/symbol/symbol/blob/dev/sdk/javascript/src/utils/base32.js
DECODED_BLOCK_SIZE = 5;
ENCODED_BLOCK_SIZE = 8;
Char_To_Decoded_Char_Map = (() => {
const builder = charMapping.createBuilder();
builder.addRange('A', 'Z', 0);
builder.addRange('2', '7', 26);
return builder.map;
})();
decodeChar = c => {
const decodedChar = Char_To_Decoded_Char_Map[c];
if (undefined !== decodedChar)
return decodedChar;
throw Error(`illegal base32 character ${c}`);
};
decodeBlock = (input, inputOffset, output, outputOffset) => {
const bytes = new Uint8Array(ENCODED_BLOCK_SIZE);
for (let i = 0; i < ENCODED_BLOCK_SIZE; ++i)
bytes[i] = decodeChar(input[inputOffset + i]);
output[outputOffset + 0] = (bytes[0] << 3) | (bytes[1] >> 2);
output[outputOffset + 1] = ((bytes[1] & 0x03) << 6) | (bytes[2] << 1) | (bytes[3] >> 4);
output[outputOffset + 2] = ((bytes[3] & 0x0F) << 4) | (bytes[4] >> 1);
output[outputOffset + 3] = ((bytes[4] & 0x01) << 7) | (bytes[5] << 2) | (bytes[6] >> 3);
output[outputOffset + 4] = ((bytes[6] & 0x07) << 5) | bytes[7];
};
base32 = {
decode: encoded => {
if (0 !== encoded.length % ENCODED_BLOCK_SIZE)
throw Error(`encoded size must be multiple of ${ENCODED_BLOCK_SIZE}`);
const output = new Uint8Array(encoded.length / ENCODED_BLOCK_SIZE * DECODED_BLOCK_SIZE);
for (let i = 0; i < encoded.length / ENCODED_BLOCK_SIZE; ++i)
decodeBlock(encoded, i * ENCODED_BLOCK_SIZE, output, i * DECODED_BLOCK_SIZE);
return output;
}
};