Symbol SDK v3を使ったトランザクション周りの逆引きリファレンスです。
今回はトランザクションの作成・署名・アナウンスをまとめています。
この記事の概要(おさらい)
- ある程度TS/ESMに慣れた人向けで前提条件などは省いています。
-
v3.3.0に準拠しています。特にv3.1.x系とv3.2.0には破壊的変更が多く、互換性がない場合があります。 - 9月のアップデートにより、
v3.2.0では一部のトランザクションが非互換となっています。
⚠️ 注意事項
この記事で使用している秘密鍵・ニーモニックは学習・テスト目的のサンプルです。
- 実際の運用では必ず新規生成した秘密鍵を使用し、厳重に管理してください
- 本記事のコードを使用して発生したいかなる損害についても、筆者は一切の責任を負いません
トランザクション
前提条件
このセクションのすべてのコード例では、以下のセットアップを前提としています。
import { PrivateKey } from 'symbol-sdk';
import { SymbolAccount, SymbolFacade, descriptors, models } from 'symbol-sdk/symbol';
// "testnet" / "mainnet" の文字列でFacadeを生成できる
const facade = new SymbolFacade('testnet');
// 定数
const TESTNET_XYM_ID = 0x72c0212e67a08bcen; // symbol.xym
const AMOUNT_0_1_XYM = 100_000n;
// ノードURL(実際にアナウンスする場合に使用)
const NODE = '';
// アカウントの秘密鍵(実際の運用では環境変数などから取得してください)
const ALICE_PRIVATE_KEY = '';
const BOB_PRIVATE_KEY = '';
const CAROL_PRIVATE_KEY = '';
// アカウントの作成
const aliceAccount = facade.createAccount(new PrivateKey(ALICE_PRIVATE_KEY));
const bobAccount = facade.createAccount(new PrivateKey(BOB_PRIVATE_KEY));
const carolAccount = facade.createAccount(new PrivateKey(CAROL_PRIVATE_KEY));
console.log('alice.address', aliceAccount.address.toString());
// => alice.address TABCD...
console.log('bob.address', bobAccount.address.toString());
// => bob.address TEFGH...
console.log('carol.address', carolAccount.address.toString());
// => carol.address TIJKL...
トランザクションのアナウンス(送信)について
トランザクションをネットワークに送信するには、以下のようなコードを使用します。
jsonPayloadをノードのAPIに対してPUTする点はすべてのトランザクションで同じなのでコードではアナウンスまでは記載していません。
const announceTransaction = async (jsonPayload: string) => {
const res = await fetch(new URL('/transactions', NODE), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: jsonPayload,
}).then((r) => r.json());
console.log('announce.response', res);
};
転送トランザクション
基本的な転送トランザクションを作成して署名し、payload/hashを確認する
const messageData = new Uint8Array(); // 空メッセージ
const descriptor = new descriptors.TransferTransactionV1Descriptor(
bobAccount.address,
[
new descriptors.UnresolvedMosaicDescriptor(
new models.UnresolvedMosaicId(TESTNET_XYM_ID),
new models.Amount(AMOUNT_0_1_XYM),
),
],
messageData,
);
// feeMultiplier=100, deadline=2時間
const tx = facade.createTransactionFromTypedDescriptor(
descriptor,
aliceAccount.publicKey,
100,
60 * 60 * 2,
);
// 署名
const signature = aliceAccount.signTransaction(tx);
// payload(JSON文字列)を生成
const jsonPayload = facade.transactionFactory.static.attachSignature(tx, signature);
// hash は tx本体から計算できる(オフライン)
const hash = facade.hashTransaction(tx).toString();
console.log('tx.size', tx.size);
// => tx.size 176
console.log('tx.hash', hash);
// => tx.hash A1B2C3D4E5F6... (64文字のトランザクションハッシュ)
console.log('payload(json)', jsonPayload);
// => payload(json) {"payload":"B0000000..."}
※実際にアナウンスしたい場合は上記の jsonPayload を先述の announceTransaction メソッドでアナウンスしてください。(以降も同様)
平文テキストメッセージ付きの転送トランザクションを作成して署名する
const message = 'Hello, Symbol!';
const messageData = new Uint8Array([0x00, ...new TextEncoder().encode(message)]);
const descriptor = new descriptors.TransferTransactionV1Descriptor(
bobAccount.address,
[
new descriptors.UnresolvedMosaicDescriptor(
new models.UnresolvedMosaicId(TESTNET_XYM_ID),
new models.Amount(AMOUNT_0_1_XYM),
),
],
messageData,
);
const tx = facade.createTransactionFromTypedDescriptor(
descriptor,
aliceAccount.publicKey,
100,
60 * 60 * 2,
);
const signature = aliceAccount.signTransaction(tx);
const jsonPayload = facade.transactionFactory.static.attachSignature(tx, signature);
const hash = facade.hashTransaction(tx).toString();
console.log('message', message);
// => message Hello, Symbol!
console.log('tx.hash', hash);
// => tx.hash C1D2E3F4G5H6... (64文字のトランザクションハッシュ)
console.log('payload(json)', jsonPayload);
// => payload(json) {"payload":"C0000000..."}
バイナリメッセージ付きの転送トランザクションを作成して署名する
生データは先頭に 0xFF を付けることで、バイナリメッセージとして扱われます。
const rawBytes = new Uint8Array([0x10, 0x20, 0x30]);
const messageData = new Uint8Array([0xff, ...rawBytes]);
const descriptor = new descriptors.TransferTransactionV1Descriptor(
bobAccount.address,
[
new descriptors.UnresolvedMosaicDescriptor(
new models.UnresolvedMosaicId(TESTNET_XYM_ID),
new models.Amount(AMOUNT_0_1_XYM),
),
],
messageData,
);
const tx = facade.createTransactionFromTypedDescriptor(
descriptor,
aliceAccount.publicKey,
100,
60 * 60 * 2,
);
const signature = aliceAccount.signTransaction(tx);
const jsonPayload = facade.transactionFactory.static.attachSignature(tx, signature);
const hash = facade.hashTransaction(tx).toString();
console.log('message(rawBytes)', Buffer.from(rawBytes).toString('hex').toUpperCase());
// => message(rawBytes) 102030
console.log('tx.hash', hash);
// => tx.hash E1F2G3H4I5J6... (64文字のトランザクションハッシュ)
console.log('payload(json)', jsonPayload);
// => payload(json) {"payload":"D0000000..."}
暗号化メッセージ(互換性)
Symbol SDK v3 の暗号化メッセージには「推奨形式」と「Wallet互換形式(deprecated)」があります。
-
推奨形式:
MessageEncoder.encode()-
0x01+ (tag + iv + ciphertext のバイナリ)
-
-
Wallet互換形式(sdk v2.x.xでも読める形式):
MessageEncoder.encodeDeprecated()-
0x01+ (上記バイナリ部分をさらにhex文字列(ASCII)化したもの)
-
Symbol Wallet は現在内部でsdk v2.xを使っているようですので推奨形式で送ったメッセージは正しく表示/復号できないことがあります。
暗号化メッセージ(SDK推奨形式)付きの転送トランザクションを作成して署名する
const plain = new TextEncoder().encode('Hello Symbol!');
const messageData = aliceAccount.messageEncoder().encode(bobAccount.publicKey, plain);
console.log('messageData[0]', messageData[0]);
// => messageData[0] 1
const descriptor = new descriptors.TransferTransactionV1Descriptor(
bobAccount.address,
[
new descriptors.UnresolvedMosaicDescriptor(
new models.UnresolvedMosaicId(TESTNET_XYM_ID),
new models.Amount(AMOUNT_0_1_XYM),
),
],
messageData,
);
const tx = facade.createTransactionFromTypedDescriptor(
descriptor,
aliceAccount.publicKey,
100,
60 * 60 * 2,
);
const signature = aliceAccount.signTransaction(tx);
const jsonPayload = facade.transactionFactory.static.attachSignature(tx, signature);
const hash = facade.hashTransaction(tx).toString();
console.log('message(plain)', new TextDecoder().decode(plain));
// => message(plain) Hello Symbol!
console.log('message(encryptedHex)', Buffer.from(messageData).toString('hex').toUpperCase());
// => message(encryptedHex) 01A1B2C3D4E5F6...
console.log('tx.hash', hash);
// => tx.hash F1G2H3I4J5K6... (64文字のトランザクションハッシュ)
console.log('payload(json)', jsonPayload);
// => payload(json) {"payload":"E0000000..."}
暗号化メッセージ(Wallet互換形式)付きの転送トランザクションを作成して署名する
const plain = new TextEncoder().encode('Hello Symbol!');
const messageData = aliceAccount
.messageEncoder()
.encodeDeprecated(bobAccount.publicKey, plain);
console.log('messageData[0]', messageData[0]);
// => messageData[0] 1
const encodedHexString = new TextDecoder().decode(messageData.subarray(1));
console.log('encodedHexString', encodedHexString);
// => encodedHexString A1B2C3D4E5F6... (hex文字列)
const descriptor = new descriptors.TransferTransactionV1Descriptor(
bobAccount.address,
[
new descriptors.UnresolvedMosaicDescriptor(
new models.UnresolvedMosaicId(TESTNET_XYM_ID),
new models.Amount(AMOUNT_0_1_XYM),
),
],
messageData,
);
const tx = facade.createTransactionFromTypedDescriptor(
descriptor,
aliceAccount.publicKey,
100,
60 * 60 * 2,
);
const signature = aliceAccount.signTransaction(tx);
const jsonPayload = facade.transactionFactory.static.attachSignature(tx, signature);
const hash = facade.hashTransaction(tx).toString();
console.log('message(plain)', new TextDecoder().decode(plain));
// => message(plain) Hello Symbol!
console.log('message(encryptedHexString)', encodedHexString);
// => message(encryptedHexString) A1B2C3D4E5F6...
console.log('tx.hash', hash);
// => tx.hash G1H2I3J4K5L6... (64文字のトランザクションハッシュ)
console.log('payload(json)', jsonPayload);
// => payload(json) {"payload":"F0000000..."}
暗号化メッセージの復号(オフライン)
送金txを取得して…ではなく、messageDataそのものを復号して挙動を確認します(ネットワーク不要)。
推奨形式: encode() → tryDecode() で復号できる
const plain = new TextEncoder().encode('Hello Symbol!');
const messageData = aliceAccount.messageEncoder().encode(bobAccount.publicKey, plain);
const decoded = bobAccount
.messageEncoder()
.tryDecode(aliceAccount.publicKey, messageData);
console.log('decoded.isDecoded', decoded.isDecoded);
// => decoded.isDecoded true
console.log('decoded.message', new TextDecoder().decode(decoded.message));
// => decoded.message Hello Symbol!
互換形式: encodeDeprecated() → tryDecodeDeprecated() で復号できる
現状、公式ウォレットから送ったメッセージをデコードする場合はこれ
const plain = new TextEncoder().encode('Hello Symbol!');
const messageData = aliceAccount
.messageEncoder()
.encodeDeprecated(bobAccount.publicKey, plain);
const decoded = bobAccount
.messageEncoder()
.tryDecodeDeprecated(aliceAccount.publicKey, messageData);
console.log('decoded.isDecoded', decoded.isDecoded);
// => decoded.isDecoded true
console.log('decoded.message', new TextDecoder().decode(decoded.message));
// => decoded.message Hello Symbol!
アグリゲートトランザクション
シンプルなアグリゲートコンプリートtxを作成して署名する(Alice→Bob / Alice→Carol)
アグリゲートトランザクションは、複数のトランザクションをまとめて1つのトランザクションとして実行できます。
// Step1: 内包トランザクションのDescriptorを作成
const messageData = new Uint8Array();
// 内包トランザクション1: Alice→Bobへの転送tx
const innerDescriptor1 = new descriptors.TransferTransactionV1Descriptor(
bobAccount.address,
[
new descriptors.UnresolvedMosaicDescriptor(
new models.UnresolvedMosaicId(TESTNET_XYM_ID),
new models.Amount(AMOUNT_0_1_XYM),
),
],
messageData,
);
// 内包トランザクション2: Alice→Carolへの転送tx
const innerDescriptor2 = new descriptors.TransferTransactionV1Descriptor(
carolAccount.address,
[
new descriptors.UnresolvedMosaicDescriptor(
new models.UnresolvedMosaicId(TESTNET_XYM_ID),
new models.Amount(AMOUNT_0_1_XYM),
),
],
messageData,
);
// Step2: 内包トランザクションを生成(EmbeddedTransaction)
const innerTx1 = facade.createEmbeddedTransactionFromTypedDescriptor(
innerDescriptor1,
aliceAccount.publicKey,
);
const innerTx2 = facade.createEmbeddedTransactionFromTypedDescriptor(
innerDescriptor2,
aliceAccount.publicKey,
);
// Step3: 内包トランザクションのハッシュを計算し、アグリゲートトランザクションを作成
const transactionsHash = facade.static.hashEmbeddedTransactions([innerTx1, innerTx2]);
const aggregateDescriptor = new descriptors.AggregateCompleteTransactionV3Descriptor(
transactionsHash,
[innerTx1, innerTx2],
);
const aggregateTx = facade.createTransactionFromTypedDescriptor(
aggregateDescriptor,
aliceAccount.publicKey,
100,
60 * 60 * 2,
0, // cosignatureCount
) as models.AggregateCompleteTransactionV3;
// Step4: 署名してpayloadを生成
const signature = aliceAccount.signTransaction(aggregateTx);
const jsonPayload = facade.transactionFactory.static.attachSignature(aggregateTx, signature);
const hash = facade.hashTransaction(aggregateTx).toString();
console.log('aggregate.hash', hash);
// => aggregate.hash H1I2J3K4L5M6... (64文字のトランザクションハッシュ)
console.log('aggregate.payload(json)', jsonPayload);
// => aggregate.payload(json) {"payload":"G0000000..."}
実務で使えそうなアグリゲートトランザクションの利用例
実用例: オフラインで受け取った署名済みpayload(JSON)を送信する準備をする
アグリゲートボンデッドトランザクションは前述の通りデポジットが必要だったり期限に制約があったりと、実運用するには少し難しい部分があります。
そこで、別端末/別プロセスで生成された署名済みpayloadを受け取った想定で、payload(JSON)を最低限バリデーションしてから送信する手順を示します。
// --- 以下は「署名済みpayload生成側」の処理(例) ---
// 実際の運用では、この部分は別の端末やプロセスで実行される
const messageData = new Uint8Array();
const innerDescriptor = new descriptors.TransferTransactionV1Descriptor(
bobAccount.address,
[],
messageData,
);
const innerTx = facade.createEmbeddedTransactionFromTypedDescriptor(
innerDescriptor,
aliceAccount.publicKey,
);
const transactionsHash = facade.static.hashEmbeddedTransactions([innerTx]);
const aggregateDescriptor = new descriptors.AggregateBondedTransactionV3Descriptor(
transactionsHash,
[innerTx],
);
const aggregateTx = facade.createTransactionFromTypedDescriptor(
aggregateDescriptor,
aliceAccount.publicKey,
100,
60 * 60 * 2,
0,
) as models.AggregateBondedTransactionV3;
const signature = aliceAccount.signTransaction(aggregateTx);
const payloadJson = facade.transactionFactory.static.attachSignature(aggregateTx, signature);
// --- 以下は「受信側」の処理 ---
// 署名済みpayloadを受け取った想定
console.log('receivedPayload(json)', payloadJson);
// => receivedPayload(json) {"payload":"I0000000..."}
// JSONをパースしてバリデーション
const parsed = JSON.parse(payloadJson) as { payload: string };
console.log('parsed.payload', parsed.payload);
// => parsed.payload I0000000... (hex文字列)
console.log('parsed.payload.length', parsed.payload.length);
// => parsed.payload.length 456
// そのままアナウンスできる
console.log(
'announceExample',
`fetch(new URL('/transactions', NODE), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: payloadJson })`,
);
実用例: 受信側が手数料を負担してモザイクを送信する(Bobが手数料負担 + Aliceが送金)
「Alice資産をBobが受け取る」ようなトランザクションは 通常はAlice が署名者です。
ただし aggregate の起案者(外側Tx署名者)を Bob にすれば、Bob が手数料を負担できます。
// Step1: 内包トランザクション(Alice→Bob)を作成
// 注意: 送金元はAliceだが、手数料はBobが負担する
const messageData = new Uint8Array();
const innerDescriptor = new descriptors.TransferTransactionV1Descriptor(
bobAccount.address,
[
new descriptors.UnresolvedMosaicDescriptor(
new models.UnresolvedMosaicId(TESTNET_XYM_ID),
new models.Amount(AMOUNT_0_1_XYM),
),
],
messageData,
);
const innerTx = facade.createEmbeddedTransactionFromTypedDescriptor(
innerDescriptor,
aliceAccount.publicKey, // 内包トランザクションの署名者はAlice
);
// Step2: アグリゲートボンデッドトランザクションを作成
// 重要: 起案者をBobにすることで、手数料をBobが負担する
const transactionsHash = facade.static.hashEmbeddedTransactions([innerTx]);
const aggregateDescriptor = new descriptors.AggregateBondedTransactionV3Descriptor(
transactionsHash,
[innerTx],
);
// cosignatureCount=1(Aliceの連署枠)
const aggregateTx = facade.createTransactionFromTypedDescriptor(
aggregateDescriptor,
bobAccount.publicKey, // 起案者 = Bob(手数料負担者)
100,
60 * 60 * 2,
1,
) as models.AggregateBondedTransactionV3;
// Step3: 起案者(Bob)が署名
const signatureByBob = bobAccount.signTransaction(aggregateTx);
facade.transactionFactory.static.attachSignature(aggregateTx, signatureByBob); // tx.signature を埋める
// Step4: 連署者(Alice)が連署を追加
const cosignatureByAlice = facade.cosignTransaction(aliceAccount.keyPair, aggregateTx);
aggregateTx.cosignatures.push(cosignatureByAlice);
// Step5: 最終的なpayloadを生成
const jsonPayload = facade.transactionFactory.static.attachSignature(
aggregateTx,
signatureByBob,
);
const hash = facade.hashTransaction(aggregateTx).toString();
console.log('aggregate(hash)', hash);
// => aggregate(hash) J1K2L3M4N5O6... (64文字のトランザクションハッシュ)
console.log('aggregate.signer', 'bob');
// => aggregate.signer bob
console.log('cosignature.count', aggregateTx.cosignatures.length);
// => cosignature.count 1
console.log('aggregate.payload(json)', jsonPayload);
// => aggregate.payload(json) {"payload":"J0000000..."}
用途としては、エンタープライズ向けシステムで社員が作成したトランザクションを上長がまとめてアナウンスする(ただしbondedは使いたくない)場合とかでしょうか。
まとめ
トランザクション周りで実用的なコードは以上です。