コンテンツにスキップ
中級

ボンデッドトランザクションフローのリスニング⚓︎

アグリゲートボンデッドトランザクション は、通常の トランザクション よりも複雑なライフサイクルをたどります。 アナウンス後、ネットワークが必要なすべての参加者から 連署 を受け取る partial状態に入ります。 すべての連署が届いて初めて、トランザクションは標準の unconfirmedおよび confirmed状態へと進みます。

このチュートリアルでは、アグリゲートボンデッドトランザクション チュートリアルのアセットスワップを行いますが、ポーリングの代わりに WebSocket チャネルを使用してボンデッドのライフサイクル全体を監視します。

アカウント A がアグリゲートを構築してアナウンスする一方、アカウント B は WebSocket チャネルを購読し、連署を行い、承認を待ちます。

前提条件⚓︎

開始する前に、開発環境がセットアップされていることを確認してください。 開発環境のセットアップ を参照してください。

さらに、言語に応じた WebSocket ライブラリをインストールしてください。

websockets ライブラリをインストールします。

pip install websockets

このチュートリアルでは、Node.js 22 以降で利用可能なネイティブの WebSocket API を使用します。 追加のパッケージは必要ありません。

また、スワップを完了させるために、 XYM を持つ2つの [アカウント] (default: アカウント) と1つのカスタム モザイク が必要です。便宜上、事前に資金供給されたアカウントが提供されていますが、これらはメンテナンスされておらず資金が不足している可能性があります。

自身のアカウントを使用する場合は、以下の手順を完了してください。

  • アグリゲートトランザクションを開始するためのアカウント(アカウント A)を、 コード または ウォレット を使用して作成します。
  • スワップに参加するための2つ目のアカウント(アカウント B)を作成します。
  • トランザクション手数料、転送量、および [ハッシュロック] (default: ハッシュ) のデポジットを支払うための XYM をアカウント A で入手します。 蛇口 (Faucet) からテストネットの通貨を入手する を参照してください。
  • スワップのためにアカウント B が所有するモザイクを作成します。 モザイクの作成 を参照してください。

完全なコード⚓︎

このチュートリアルの完全なコード一覧を以下に示します。 詳細な手順ごとの説明は次のセクションで行います。

import asyncio
import json
import os
import urllib.request

from symbolchain.CryptoTypes import PrivateKey
from symbolchain.facade.SymbolFacade import SymbolFacade
from symbolchain.sc import Amount
from symbolchain.symbol.IdGenerator import generate_mosaic_alias_id
from symbolchain.symbol.Network import NetworkTimestamp
from websockets import connect

NODE_URL = os.getenv(
    'NODE_URL', 'https://reference.symboltest.net:3001')
WS_URL = NODE_URL.replace('http', 'ws', 1) + '/ws'
print(f'Using node {NODE_URL}')

ACCOUNT_A_PRIVATE_KEY = os.getenv(
    'ACCOUNT_A_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000000')
ACCOUNT_B_PRIVATE_KEY = os.getenv(
    'ACCOUNT_B_PRIVATE_KEY',
    '1111111111111111111111111111111111111111111111111111111111111111')

facade = SymbolFacade('testnet')
account_a_key_pair = SymbolFacade.KeyPair(
    PrivateKey(ACCOUNT_A_PRIVATE_KEY))
account_b_key_pair = SymbolFacade.KeyPair(
    PrivateKey(ACCOUNT_B_PRIVATE_KEY))
account_a_address = facade.network.public_key_to_address(
    account_a_key_pair.public_key)
account_b_address = facade.network.public_key_to_address(
    account_b_key_pair.public_key)
print(f'Account A: {account_a_address}')
print(f'Account B: {account_b_address}')


async def main():
    # Fetch current network time
    with urllib.request.urlopen(
        f'{NODE_URL}/node/time'
    ) as resp:
        time_json = json.loads(resp.read().decode())
        timestamp = NetworkTimestamp(int(
            time_json['communicationTimestamps']['receiveTimestamp']))

    # Fetch recommended fee multiplier
    with urllib.request.urlopen(
        f'{NODE_URL}/network/fees/transaction'
    ) as resp:
        fee_json = json.loads(resp.read().decode())
        fee_multiplier = max(
            fee_json['medianFeeMultiplier'],
            fee_json['minFeeMultiplier'])

    # [Account A] Build embedded transactions for the swap
    embedded_tx_1 = (
        facade.transaction_factory.create_embedded({
            'type': 'transfer_transaction_v1',
            'signer_public_key': account_a_key_pair.public_key,
            'recipient_address': account_b_address,
            'mosaics': [{
                'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
                'amount': 10_000_000
            }]
        }))

    custom_mosaic_id = 0x6D1314BE751B62C2
    embedded_tx_2 = (
        facade.transaction_factory.create_embedded({
            'type': 'transfer_transaction_v1',
            'signer_public_key': account_b_key_pair.public_key,
            'recipient_address': account_a_address,
            'mosaics': [{
                'mosaic_id': custom_mosaic_id,
                'amount': 1
            }]
        }))

    # Build the bonded aggregate transaction
    embedded_txs = [embedded_tx_1, embedded_tx_2]
    bonded_tx = facade.transaction_factory.create({
        'type': 'aggregate_bonded_transaction_v3',
        'signer_public_key': account_a_key_pair.public_key,
        'deadline': timestamp.add_hours(2).timestamp,
        'transactions_hash': facade.hash_embedded_transactions(
            embedded_txs),
        'transactions': embedded_txs
    })
    bonded_tx.fee = Amount(fee_multiplier * (bonded_tx.size + 104))

    # Sign the bonded aggregate
    bonded_signature = facade.sign_transaction(
        account_a_key_pair, bonded_tx)
    bonded_payload = facade.transaction_factory.attach_signature(
        bonded_tx, bonded_signature)
    bonded_hash = facade.hash_transaction(bonded_tx)
    print(
        f'[Account A] Bonded aggregate hash: {str(bonded_hash)[:16]}...')

    # Create the hash lock transaction
    hash_lock = facade.transaction_factory.create({
        'type': 'hash_lock_transaction_v1',
        'signer_public_key':
            account_a_key_pair.public_key,
        'deadline': timestamp.add_hours(2).timestamp,
        'mosaic': {
            'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
            'amount': 10_000_000
        },
        'duration': 100,
        'hash': bonded_hash
    })
    hash_lock.fee = Amount(fee_multiplier * hash_lock.size)
    hash_lock_signature = facade.sign_transaction(
        account_a_key_pair, hash_lock)
    hash_lock_payload = facade.transaction_factory.attach_signature(
        hash_lock, hash_lock_signature)
    hash_lock_hash = facade.hash_transaction(hash_lock)

    # Confirm hash lock via WebSocket
    async with connect(WS_URL) as websocket:
        response = json.loads(
            await websocket.recv())
        uid = response['uid']

        lock_channels = [
            f'confirmedAdded/{account_a_address}',
            f'status/{account_a_address}',
        ]
        for channel in lock_channels:
            await websocket.send(json.dumps(
                {'uid': uid, 'subscribe': channel}
            ))

        # Announce hash lock
        request = urllib.request.Request(
            f'{NODE_URL}/transactions',
            data=hash_lock_payload.encode(),
            headers={'Content-Type': 'application/json'},
            method='PUT'
        )
        with urllib.request.urlopen(request) as resp:
            resp.read()
        print('[Account A] Announced hash lock '
            f'{str(hash_lock_hash)[:16]}...')

        # Wait for hash lock confirmation
        async for raw_message in websocket:
            message = json.loads(raw_message)
            name = message['topic'].split('/')[0]

            if name == 'confirmedAdded':
                message_hash = (message['data']['meta']['hash'])
                if message_hash == str(hash_lock_hash):
                    print('Hash lock confirmed')
                    break

            if name == 'status':
                status_hash = (message['data']['hash'])
                if status_hash == str(hash_lock_hash):
                    raise Exception(
                    'Hash lock failed: ' +message['data']['code'])

        for channel in lock_channels:
            await websocket.send(json.dumps({
                'uid': uid,
                'unsubscribe': channel
            }))

    # [Account B] Connect to WebSocket for bonded flow
    async with connect(WS_URL) as websocket:
        response = json.loads(await websocket.recv())
        uid = response['uid']
        print(f'[Account B] Connected to {WS_URL} with uid {uid}')

        # Subscribe to bonded transaction channels
        channels = [
            f'partialAdded/{account_b_address}',
            f'partialRemoved/{account_b_address}',
            f'cosignature/{account_b_address}',
            f'unconfirmedAdded/{account_b_address}',
            f'unconfirmedRemoved/{account_b_address}',
            f'confirmedAdded/{account_b_address}',
            f'status/{account_b_address}',
        ]
        for channel in channels:
            await websocket.send(json.dumps(
                {'uid': uid, 'subscribe': channel}
            ))
            name = channel.split('/')[0]
            print(f'[Account B] Subscribed to {name} channel')

        # [Account A] Announce bonded aggregate
        request = urllib.request.Request(
            f'{NODE_URL}/transactions/partial',
            data=bonded_payload.encode(),
            headers={'Content-Type': 'application/json'},
            method='PUT'
        )
        with urllib.request.urlopen(request) as resp:
            resp.read()
        print(f'[Account A] Announced bonded {str(bonded_hash)[:16]}...')

        # [Account B] Listen for bonded transaction flow
        async for raw_message in websocket:
            message = json.loads(raw_message)
            topic = message['topic']
            name = topic.split('/')[0]

            if name == 'cosignature':
                signer = (message['data']['signerPublicKey'])
                print(f'cosignature: signer={signer[:16]}...')

            elif name == 'status':
                status_hash = message['data']['hash']
                print(f'status: hash={status_hash[:16]}...')
                if status_hash == str(bonded_hash):
                    raise Exception(
                        'Transaction failed: ' + message['data']['code'])

            elif name == 'partialAdded':
                message_hash = (message['data']['meta']['hash'])
                print(f'partialAdded: hash={message_hash[:16]}...')
                if message_hash == str(bonded_hash):
                    cosignature = facade.cosign_transaction_hash(
                            account_b_key_pair, bonded_hash, True)
                    cosignature_payload = json.dumps({
                        'version': str(cosignature.version),
                        'signerPublicKey': str(
                            cosignature.signer_public_key),
                        'signature': str(cosignature.signature),
                        'parentHash': str(cosignature.parent_hash)
                    })
                    cosignature_request = (
                        urllib.request.Request(
                            f'{NODE_URL}/transactions/cosignature',
                            data=(cosignature_payload.encode()),
                            headers={'Content-Type':'application/json'},
                            method='PUT'))
                    with urllib.request.urlopen(
                        cosignature_request
                    ) as resp:
                        resp.read()
                    print('[Account B] Submitted cosignature')

            elif name == 'confirmedAdded':
                message_hash = (message['data']['meta']['hash'])
                print(f'confirmedAdded: hash={message_hash[:16]}...')
                if message_hash == str(bonded_hash):
                    print('Transaction '
                        f'{str(bonded_hash)[:16]}... confirmed')
                    break

            else:
                message_hash = (message['data']['meta']['hash'])
                print(f'{name}: hash={message_hash[:16]}...')

        # Unsubscribe before closing
        for channel in channels:
            await websocket.send(json.dumps({
                'uid': uid,
                'unsubscribe': channel
            }))
        print('[Account B] Unsubscribed from all channels')


try:
    asyncio.run(main())
except Exception as error:
    print(error)

Download source

import { Hash256, PrivateKey } from 'symbol-sdk';
import {
    generateMosaicAliasId,
    models,
    NetworkTimestamp,
    SymbolFacade
} from 'symbol-sdk/symbol';

const NODE_URL = process.env.NODE_URL
    || 'https://reference.symboltest.net:3001';
const WS_URL = NODE_URL.replace('http', 'ws') + '/ws';
console.log(`Using node ${NODE_URL}`);

const ACCOUNT_A_PRIVATE_KEY =
    process.env.ACCOUNT_A_PRIVATE_KEY
    || '0000000000000000000000000000000000000000000000000000000000000000';
const ACCOUNT_B_PRIVATE_KEY =
    process.env.ACCOUNT_B_PRIVATE_KEY
    || '1111111111111111111111111111111111111111111111111111111111111111';

const facade = new SymbolFacade('testnet');
const accountAKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(ACCOUNT_A_PRIVATE_KEY));
const accountBKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(ACCOUNT_B_PRIVATE_KEY));
const accountAAddress = facade.network
    .publicKeyToAddress(accountAKeyPair.publicKey);
const accountBAddress = facade.network
    .publicKeyToAddress(accountBKeyPair.publicKey);
console.log('Account A:', accountAAddress.toString());
console.log('Account B:', accountBAddress.toString());

try {
    // Fetch current network time
    const timeResponse = await fetch(`${NODE_URL}/node/time`);
    const timeJSON = await timeResponse.json();
    const timestamp = new NetworkTimestamp(
        timeJSON.communicationTimestamps.receiveTimestamp);

    // Fetch recommended fee multiplier
    const feeResponse = await fetch(
        `${NODE_URL}/network/fees/transaction`);
    const feeJSON = await feeResponse.json();
    const feeMultiplier = Math.max(
        feeJSON.medianFeeMultiplier, feeJSON.minFeeMultiplier);

    // [Account A] Build embedded transactions for the swap
    const embeddedTx1 = facade.transactionFactory.createEmbedded({
        type: 'transfer_transaction_v1',
        signerPublicKey: accountAKeyPair.publicKey.toString(),
        recipientAddress: accountBAddress.toString(),
        mosaics: [{
            mosaicId: generateMosaicAliasId('symbol.xym'),
            amount: 10_000_000n
        }]
    });

    const customMosaicId = 0x6D1314BE751B62C2n;
    const embeddedTx2 = facade.transactionFactory.createEmbedded({
        type: 'transfer_transaction_v1',
        signerPublicKey: accountBKeyPair.publicKey.toString(),
        recipientAddress: accountAAddress.toString(),
        mosaics: [{ mosaicId: customMosaicId, amount: 1n }]
    });

    // Build the bonded aggregate transaction
    const embeddedTxs = [embeddedTx1, embeddedTx2];
    const bondedTx = facade.transactionFactory.create({
        type: 'aggregate_bonded_transaction_v3',
        signerPublicKey: accountAKeyPair.publicKey.toString(),
        deadline: timestamp.addHours(2).timestamp,
        transactionsHash:
            facade.static.hashEmbeddedTransactions(embeddedTxs),
        transactions: embeddedTxs
    });
    bondedTx.fee = new models.Amount(
        feeMultiplier * (bondedTx.size + 104));

    // Sign the bonded aggregate
    const bondedSignature = facade.signTransaction(
        accountAKeyPair, bondedTx);
    const bondedPayload = facade.transactionFactory
        .static.attachSignature(bondedTx, bondedSignature);
    const bondedHash = facade
        .hashTransaction(bondedTx).toString();
    console.log('[Account A] Bonded aggregate hash: '
        + `${bondedHash.substring(0, 16)}...`);

    // Create the hash lock transaction
    const hashLock = facade.transactionFactory.create({
        type: 'hash_lock_transaction_v1',
        signerPublicKey: accountAKeyPair.publicKey.toString(),
        deadline: timestamp.addHours(2).timestamp,
        mosaic: {
            mosaicId: generateMosaicAliasId('symbol.xym'),
            amount: 10_000_000n
        },
        duration: 100n,
        hash: bondedHash
    });
    hashLock.fee = new models.Amount(feeMultiplier * hashLock.size);
    const hashLockSignature = facade.signTransaction(
        accountAKeyPair, hashLock);
    const hashLockPayload = facade.transactionFactory
        .static.attachSignature(hashLock, hashLockSignature);
    const hashLockHash = facade
        .hashTransaction(hashLock).toString();

    // Confirm hash lock via WebSocket
    const lockWebSocket = new WebSocket(WS_URL);
    const lockUid = await new Promise((resolve) => {
        lockWebSocket.addEventListener('message', (event) => {
            const message = JSON.parse(event.data);
            resolve(message.uid);
        }, { once: true });
    });

    const addressA = accountAAddress.toString();
    const lockChannels = [
        `confirmedAdded/${addressA}`,
        `status/${addressA}`,
    ];
    for (const channel of lockChannels) {
        lockWebSocket.send(JSON.stringify({
            uid: lockUid, subscribe: channel
        }));
    }

    // Announce hash lock
    await fetch(`${NODE_URL}/transactions`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: hashLockPayload
    });
    console.log('[Account A] Announced hash lock '
        + `${hashLockHash.substring(0, 16)}...`);

    // Wait for hash lock confirmation
    await new Promise((resolve, reject) => {
        lockWebSocket.addEventListener('message', (event) => {
            const message = JSON.parse(event.data);
            const name = message.topic.split('/')[0];

            if (name === 'confirmedAdded'
                && message.data.meta.hash === hashLockHash) {
                console.log('Hash lock confirmed');
                resolve();
            }

            if (name === 'status' && message.data.hash === hashLockHash) {
                reject(new Error(
                    'Hash lock failed: ' + message.data.code));
            }
        });
    });

    for (const channel of lockChannels) {
        lockWebSocket.send(JSON.stringify({
            uid: lockUid, unsubscribe: channel
        }));
    }
    lockWebSocket.close();

    // [Account B] Connect to WebSocket for bonded flow
    const websocket = new WebSocket(WS_URL);
    const uid = await new Promise((resolve) => {
        websocket.addEventListener('message', (event) => {
            const message = JSON.parse(event.data);
            resolve(message.uid);
        }, { once: true });
    });
    console.log(`[Account B] Connected to ${WS_URL} with uid ${uid}`);

    // Subscribe to bonded transaction channels
    const addressB = accountBAddress.toString();
    const channels = [
        `partialAdded/${addressB}`,
        `partialRemoved/${addressB}`,
        `cosignature/${addressB}`,
        `unconfirmedAdded/${addressB}`,
        `unconfirmedRemoved/${addressB}`,
        `confirmedAdded/${addressB}`,
        `status/${addressB}`,
    ];
    for (const channel of channels) {
        websocket.send(JSON.stringify({uid, subscribe: channel}));
        const name = channel.split('/')[0];
        console.log(`[Account B] Subscribed to ${name} channel`);
    }

    // [Account B] Listen for bonded transaction flow
    const confirmed = new Promise((resolve, reject) => {
        websocket.addEventListener('message', (event) => {
            const message = JSON.parse(event.data);
            const topic = message.topic;
            const name = topic.split('/')[0];

            if (name === 'cosignature') {
                const signer = message.data.signerPublicKey;
                console.log(
                    `cosignature: signer=${signer.substring(0, 16)}...`);

            } else if (name === 'status') {
                const statusHash = message.data.hash;
                console.log(
                    `status: hash=${statusHash.substring(0, 16)}...`);
                if (statusHash === bondedHash) {
                    reject(new Error(
                        'Transaction failed: ' + message.data.code));
                    return;
                }

            } else if (name === 'partialAdded') {
                const messageHash = message.data.meta.hash;
                console.log('partialAdded: hash='
                    + `${messageHash.substring(0, 16)}...`);
                if (messageHash === bondedHash) {
                    const cosignature =
                        SymbolFacade.cosignTransactionHash(
                            accountBKeyPair,
                            new Hash256(bondedHash), true);
                    const cosignaturePayload = JSON.stringify({
                        version: cosignature.version.toString(),
                        signerPublicKey:
                            cosignature.signerPublicKey.toString(),
                        signature: cosignature.signature.toString(),
                        parentHash: cosignature.parentHash.toString()
                    });
                    fetch(`${NODE_URL}/transactions/cosignature`, {
                        method: 'PUT',
                        headers: {'Content-Type': 'application/json'},
                        body: cosignaturePayload
                    }).then(() => console.log(
                        '[Account B] Submitted cosignature'))
                    .catch((err) => console.error(
                        'Cosignature failed:', err));
                }

            } else if (name === 'confirmedAdded') {
                const messageHash = message.data.meta.hash;
                console.log(`confirmedAdded: hash=`
                    + `${messageHash.substring(0, 16)}...`);
                if (messageHash === bondedHash) {
                    console.log('Transaction '
                        + bondedHash.substring(0, 16) + '... confirmed');
                    resolve();
                }

            } else {
                const messageHash = message.data.meta.hash;
                console.log(
                    `${name}: hash=${messageHash.substring(0, 16)}...`);
            }
        });
    });

    // [Account A] Announce bonded aggregate
    await fetch(`${NODE_URL}/transactions/partial`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: bondedPayload
    });
    console.log('[Account A] Announced bonded '
        + `${bondedHash.substring(0, 16)}...`);

    // Wait for confirmation via WebSocket
    await confirmed;

    // Unsubscribe before closing
    for (const channel of channels) {
        websocket.send(JSON.stringify({uid, unsubscribe: channel}));
    }
    console.log('[Account B] Unsubscribed from all channels');
    websocket.close();
} catch (error) {
    console.error(error);
}

Download source

アグリゲートボンデッドトランザクションには、2つの異なる役割が含まれます。アグリゲートを構築、署名、アナウンスする 開始者 (アカウント A)と、WebSocket チャネルを監視し、トランザクションを検証した後に連署する1人以上の 連署者 (アカウント B)です。

実際には、それぞれの役割は別々のマシンの別々のプログラムとして実行され、すべての連署者は開始者がアグリゲートボンデッドを送信する前にすでにリスニング状態(待ち受け状態)になっている必要があります。 このチュートリアルでは、簡略化のため両方の役割を1つのスクリプトにまとめています。

コード解説⚓︎

アカウント A: アカウントの設定⚓︎

ACCOUNT_A_PRIVATE_KEY = os.getenv(
    'ACCOUNT_A_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000000')
ACCOUNT_B_PRIVATE_KEY = os.getenv(
    'ACCOUNT_B_PRIVATE_KEY',
    '1111111111111111111111111111111111111111111111111111111111111111')

facade = SymbolFacade('testnet')
account_a_key_pair = SymbolFacade.KeyPair(
    PrivateKey(ACCOUNT_A_PRIVATE_KEY))
account_b_key_pair = SymbolFacade.KeyPair(
    PrivateKey(ACCOUNT_B_PRIVATE_KEY))
account_a_address = facade.network.public_key_to_address(
    account_a_key_pair.public_key)
account_b_address = facade.network.public_key_to_address(
    account_b_key_pair.public_key)
print(f'Account A: {account_a_address}')
print(f'Account B: {account_b_address}')
const ACCOUNT_A_PRIVATE_KEY =
    process.env.ACCOUNT_A_PRIVATE_KEY
    || '0000000000000000000000000000000000000000000000000000000000000000';
const ACCOUNT_B_PRIVATE_KEY =
    process.env.ACCOUNT_B_PRIVATE_KEY
    || '1111111111111111111111111111111111111111111111111111111111111111';

const facade = new SymbolFacade('testnet');
const accountAKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(ACCOUNT_A_PRIVATE_KEY));
const accountBKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(ACCOUNT_B_PRIVATE_KEY));
const accountAAddress = facade.network
    .publicKeyToAddress(accountAKeyPair.publicKey);
const accountBAddress = facade.network
    .publicKeyToAddress(accountBKeyPair.publicKey);
console.log('Account A:', accountAAddress.toString());
console.log('Account B:', accountBAddress.toString());

この例では、簡略化のため1つのスクリプトに両方の 秘密鍵 を含めています。実際には、各当事者が自身のマシンで 署名 します。 アカウント A は、埋め込みトランザクション の署名者としてアカウント B を設定し、B の アドレス を派生させるために、アカウント B の 公開鍵 のみを必要とします。

環境変数 ACCOUNT_A_PRIVATE_KEYACCOUNT_B_PRIVATE_KEY で各アカウントの鍵を設定します。設定されない場合は、デフォルトでテストキーが使用されます。 自身の鍵を使用する場合は、アカウント A が XYM を持ち、アカウント B がスワップ用のカスタムモザイクを保持していることを確認してください。 アドレスは、ファサードのネットワーク設定を使用して公開鍵から派生します。

アカウント A: アグリゲートの構築とハッシュロックのアナウンス⚓︎

    # [Account A] Build embedded transactions for the swap
    embedded_tx_1 = (
        facade.transaction_factory.create_embedded({
            'type': 'transfer_transaction_v1',
            'signer_public_key': account_a_key_pair.public_key,
            'recipient_address': account_b_address,
            'mosaics': [{
                'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
                'amount': 10_000_000
            }]
        }))

    custom_mosaic_id = 0x6D1314BE751B62C2
    embedded_tx_2 = (
        facade.transaction_factory.create_embedded({
            'type': 'transfer_transaction_v1',
            'signer_public_key': account_b_key_pair.public_key,
            'recipient_address': account_a_address,
            'mosaics': [{
                'mosaic_id': custom_mosaic_id,
                'amount': 1
            }]
        }))

    # Build the bonded aggregate transaction
    embedded_txs = [embedded_tx_1, embedded_tx_2]
    bonded_tx = facade.transaction_factory.create({
        'type': 'aggregate_bonded_transaction_v3',
        'signer_public_key': account_a_key_pair.public_key,
        'deadline': timestamp.add_hours(2).timestamp,
        'transactions_hash': facade.hash_embedded_transactions(
            embedded_txs),
        'transactions': embedded_txs
    })
    bonded_tx.fee = Amount(fee_multiplier * (bonded_tx.size + 104))

    # Sign the bonded aggregate
    bonded_signature = facade.sign_transaction(
        account_a_key_pair, bonded_tx)
    bonded_payload = facade.transaction_factory.attach_signature(
        bonded_tx, bonded_signature)
    bonded_hash = facade.hash_transaction(bonded_tx)
    print(
        f'[Account A] Bonded aggregate hash: {str(bonded_hash)[:16]}...')

    # Create the hash lock transaction
    hash_lock = facade.transaction_factory.create({
        'type': 'hash_lock_transaction_v1',
        'signer_public_key':
            account_a_key_pair.public_key,
        'deadline': timestamp.add_hours(2).timestamp,
        'mosaic': {
            'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
            'amount': 10_000_000
        },
        'duration': 100,
        'hash': bonded_hash
    })
    hash_lock.fee = Amount(fee_multiplier * hash_lock.size)
    hash_lock_signature = facade.sign_transaction(
        account_a_key_pair, hash_lock)
    hash_lock_payload = facade.transaction_factory.attach_signature(
        hash_lock, hash_lock_signature)
    hash_lock_hash = facade.hash_transaction(hash_lock)

    # Confirm hash lock via WebSocket
    async with connect(WS_URL) as websocket:
        response = json.loads(
            await websocket.recv())
        uid = response['uid']

        lock_channels = [
            f'confirmedAdded/{account_a_address}',
            f'status/{account_a_address}',
        ]
        for channel in lock_channels:
            await websocket.send(json.dumps(
                {'uid': uid, 'subscribe': channel}
            ))

        # Announce hash lock
        request = urllib.request.Request(
            f'{NODE_URL}/transactions',
            data=hash_lock_payload.encode(),
            headers={'Content-Type': 'application/json'},
            method='PUT'
        )
        with urllib.request.urlopen(request) as resp:
            resp.read()
        print('[Account A] Announced hash lock '
            f'{str(hash_lock_hash)[:16]}...')

        # Wait for hash lock confirmation
        async for raw_message in websocket:
            message = json.loads(raw_message)
            name = message['topic'].split('/')[0]

            if name == 'confirmedAdded':
                message_hash = (message['data']['meta']['hash'])
                if message_hash == str(hash_lock_hash):
                    print('Hash lock confirmed')
                    break

            if name == 'status':
                status_hash = (message['data']['hash'])
                if status_hash == str(hash_lock_hash):
                    raise Exception(
                    'Hash lock failed: ' +message['data']['code'])

        for channel in lock_channels:
            await websocket.send(json.dumps({
                'uid': uid,
                'unsubscribe': channel
            }))
    // [Account A] Build embedded transactions for the swap
    const embeddedTx1 = facade.transactionFactory.createEmbedded({
        type: 'transfer_transaction_v1',
        signerPublicKey: accountAKeyPair.publicKey.toString(),
        recipientAddress: accountBAddress.toString(),
        mosaics: [{
            mosaicId: generateMosaicAliasId('symbol.xym'),
            amount: 10_000_000n
        }]
    });

    const customMosaicId = 0x6D1314BE751B62C2n;
    const embeddedTx2 = facade.transactionFactory.createEmbedded({
        type: 'transfer_transaction_v1',
        signerPublicKey: accountBKeyPair.publicKey.toString(),
        recipientAddress: accountAAddress.toString(),
        mosaics: [{ mosaicId: customMosaicId, amount: 1n }]
    });

    // Build the bonded aggregate transaction
    const embeddedTxs = [embeddedTx1, embeddedTx2];
    const bondedTx = facade.transactionFactory.create({
        type: 'aggregate_bonded_transaction_v3',
        signerPublicKey: accountAKeyPair.publicKey.toString(),
        deadline: timestamp.addHours(2).timestamp,
        transactionsHash:
            facade.static.hashEmbeddedTransactions(embeddedTxs),
        transactions: embeddedTxs
    });
    bondedTx.fee = new models.Amount(
        feeMultiplier * (bondedTx.size + 104));

    // Sign the bonded aggregate
    const bondedSignature = facade.signTransaction(
        accountAKeyPair, bondedTx);
    const bondedPayload = facade.transactionFactory
        .static.attachSignature(bondedTx, bondedSignature);
    const bondedHash = facade
        .hashTransaction(bondedTx).toString();
    console.log('[Account A] Bonded aggregate hash: '
        + `${bondedHash.substring(0, 16)}...`);

    // Create the hash lock transaction
    const hashLock = facade.transactionFactory.create({
        type: 'hash_lock_transaction_v1',
        signerPublicKey: accountAKeyPair.publicKey.toString(),
        deadline: timestamp.addHours(2).timestamp,
        mosaic: {
            mosaicId: generateMosaicAliasId('symbol.xym'),
            amount: 10_000_000n
        },
        duration: 100n,
        hash: bondedHash
    });
    hashLock.fee = new models.Amount(feeMultiplier * hashLock.size);
    const hashLockSignature = facade.signTransaction(
        accountAKeyPair, hashLock);
    const hashLockPayload = facade.transactionFactory
        .static.attachSignature(hashLock, hashLockSignature);
    const hashLockHash = facade
        .hashTransaction(hashLock).toString();

    // Confirm hash lock via WebSocket
    const lockWebSocket = new WebSocket(WS_URL);
    const lockUid = await new Promise((resolve) => {
        lockWebSocket.addEventListener('message', (event) => {
            const message = JSON.parse(event.data);
            resolve(message.uid);
        }, { once: true });
    });

    const addressA = accountAAddress.toString();
    const lockChannels = [
        `confirmedAdded/${addressA}`,
        `status/${addressA}`,
    ];
    for (const channel of lockChannels) {
        lockWebSocket.send(JSON.stringify({
            uid: lockUid, subscribe: channel
        }));
    }

    // Announce hash lock
    await fetch(`${NODE_URL}/transactions`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: hashLockPayload
    });
    console.log('[Account A] Announced hash lock '
        + `${hashLockHash.substring(0, 16)}...`);

    // Wait for hash lock confirmation
    await new Promise((resolve, reject) => {
        lockWebSocket.addEventListener('message', (event) => {
            const message = JSON.parse(event.data);
            const name = message.topic.split('/')[0];

            if (name === 'confirmedAdded'
                && message.data.meta.hash === hashLockHash) {
                console.log('Hash lock confirmed');
                resolve();
            }

            if (name === 'status' && message.data.hash === hashLockHash) {
                reject(new Error(
                    'Hash lock failed: ' + message.data.code));
            }
        });
    });

    for (const channel of lockChannels) {
        lockWebSocket.send(JSON.stringify({
            uid: lockUid, unsubscribe: channel
        }));
    }
    lockWebSocket.close();

アカウント A は、 アグリゲートボンデッドトランザクション チュートリアルで説明されているのと同じパターンに従い、アカウント B の 1 つのカスタムモザイクと 10 XYM を交換するアグリゲートボンデッドを作成して署名し、必要なハッシュロックをアナウンスします。

唯一の違いは、ハッシュロックを確認するために /transactionStatus/{hash} GET をポーリングする代わりに、このチュートリアルでは トランザクションフローのリスニング チュートリアルで説明されているものと同じアプローチに従って WebSocket を使用することです。

アカウント B: 接続とチャネルのサブスクライブ⚓︎

    # [Account B] Connect to WebSocket for bonded flow
    async with connect(WS_URL) as websocket:
        response = json.loads(await websocket.recv())
        uid = response['uid']
        print(f'[Account B] Connected to {WS_URL} with uid {uid}')

        # Subscribe to bonded transaction channels
        channels = [
            f'partialAdded/{account_b_address}',
            f'partialRemoved/{account_b_address}',
            f'cosignature/{account_b_address}',
            f'unconfirmedAdded/{account_b_address}',
            f'unconfirmedRemoved/{account_b_address}',
            f'confirmedAdded/{account_b_address}',
            f'status/{account_b_address}',
        ]
        for channel in channels:
            await websocket.send(json.dumps(
                {'uid': uid, 'subscribe': channel}
            ))
            name = channel.split('/')[0]
            print(f'[Account B] Subscribed to {name} channel')
    // [Account B] Connect to WebSocket for bonded flow
    const websocket = new WebSocket(WS_URL);
    const uid = await new Promise((resolve) => {
        websocket.addEventListener('message', (event) => {
            const message = JSON.parse(event.data);
            resolve(message.uid);
        }, { once: true });
    });
    console.log(`[Account B] Connected to ${WS_URL} with uid ${uid}`);

    // Subscribe to bonded transaction channels
    const addressB = accountBAddress.toString();
    const channels = [
        `partialAdded/${addressB}`,
        `partialRemoved/${addressB}`,
        `cosignature/${addressB}`,
        `unconfirmedAdded/${addressB}`,
        `unconfirmedRemoved/${addressB}`,
        `confirmedAdded/${addressB}`,
        `status/${addressB}`,
    ];
    for (const channel of channels) {
        websocket.send(JSON.stringify({uid, subscribe: channel}));
        const name = channel.split('/')[0];
        console.log(`[Account B] Subscribed to ${name} channel`);
    }

このスニペットでは、 NODE_URL 環境変数を使用して Symbol API ノード を設定します。値が指定されない場合は、デフォルト値が使用されます。 WebSocket URL は、HTTP プロトコルを WebSocket プロトコルに置き換え、 /ws を追加することで NODE_URL から派生します。

アカウント B は WebSocket 接続を開き、ボンデッドトランザクションのライフサイクルを監視するために、自身のアドレスをスコープとするチャネルをサブスクライブします。 アカウント B はアグリゲートの参加者であるため、ノードはトランザクションのすべてのライフサイクルイベントをアカウント B のアドレスに配信します。 通常のトランザクション で使用されるチャネルに加えて、ボンデッドアグリゲートは追加のチャネルを使用します。

  • partialAdded/{address} WS: ボンデッドアグリゲートが partial 状態になり、連署を待っているときに通知します。
  • partialRemoved/{address} WS: ボンデッドアグリゲートが partial 状態を抜けたとき(すべての連署が収集されたか、期限が切れたとき)に通知します。
  • cosignature/{address} WS: 部分的トランザクションに連署が追加されたときに通知します。

アカウント A: ボンデッドアグリゲートのアナウンス⚓︎

        # [Account A] Announce bonded aggregate
        request = urllib.request.Request(
            f'{NODE_URL}/transactions/partial',
            data=bonded_payload.encode(),
            headers={'Content-Type': 'application/json'},
            method='PUT'
        )
        with urllib.request.urlopen(request) as resp:
            resp.read()
        print(f'[Account A] Announced bonded {str(bonded_hash)[:16]}...')
    // [Account A] Announce bonded aggregate
    await fetch(`${NODE_URL}/transactions/partial`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: bondedPayload
    });
    console.log('[Account A] Announced bonded '
        + `${bondedHash.substring(0, 16)}...`);

    // Wait for confirmation via WebSocket
    await confirmed;

アカウント B のサブスクライブが完了すると、アカウント A は通常の /transactions PUT エンドポイントではなく、 /transactions/partial PUT にボンデッドアグリゲートをアナウンスします。

アカウント B: WebSocket メッセージの処理と連署⚓︎

        # [Account B] Listen for bonded transaction flow
        async for raw_message in websocket:
            message = json.loads(raw_message)
            topic = message['topic']
            name = topic.split('/')[0]

            if name == 'cosignature':
                signer = (message['data']['signerPublicKey'])
                print(f'cosignature: signer={signer[:16]}...')

            elif name == 'status':
                status_hash = message['data']['hash']
                print(f'status: hash={status_hash[:16]}...')
                if status_hash == str(bonded_hash):
                    raise Exception(
                        'Transaction failed: ' + message['data']['code'])

            elif name == 'partialAdded':
                message_hash = (message['data']['meta']['hash'])
                print(f'partialAdded: hash={message_hash[:16]}...')
                if message_hash == str(bonded_hash):
                    cosignature = facade.cosign_transaction_hash(
                            account_b_key_pair, bonded_hash, True)
                    cosignature_payload = json.dumps({
                        'version': str(cosignature.version),
                        'signerPublicKey': str(
                            cosignature.signer_public_key),
                        'signature': str(cosignature.signature),
                        'parentHash': str(cosignature.parent_hash)
                    })
                    cosignature_request = (
                        urllib.request.Request(
                            f'{NODE_URL}/transactions/cosignature',
                            data=(cosignature_payload.encode()),
                            headers={'Content-Type':'application/json'},
                            method='PUT'))
                    with urllib.request.urlopen(
                        cosignature_request
                    ) as resp:
                        resp.read()
                    print('[Account B] Submitted cosignature')

            elif name == 'confirmedAdded':
                message_hash = (message['data']['meta']['hash'])
                print(f'confirmedAdded: hash={message_hash[:16]}...')
                if message_hash == str(bonded_hash):
                    print('Transaction '
                        f'{str(bonded_hash)[:16]}... confirmed')
                    break

            else:
                message_hash = (message['data']['meta']['hash'])
                print(f'{name}: hash={message_hash[:16]}...')
    // [Account B] Listen for bonded transaction flow
    const confirmed = new Promise((resolve, reject) => {
        websocket.addEventListener('message', (event) => {
            const message = JSON.parse(event.data);
            const topic = message.topic;
            const name = topic.split('/')[0];

            if (name === 'cosignature') {
                const signer = message.data.signerPublicKey;
                console.log(
                    `cosignature: signer=${signer.substring(0, 16)}...`);

            } else if (name === 'status') {
                const statusHash = message.data.hash;
                console.log(
                    `status: hash=${statusHash.substring(0, 16)}...`);
                if (statusHash === bondedHash) {
                    reject(new Error(
                        'Transaction failed: ' + message.data.code));
                    return;
                }

            } else if (name === 'partialAdded') {
                const messageHash = message.data.meta.hash;
                console.log('partialAdded: hash='
                    + `${messageHash.substring(0, 16)}...`);
                if (messageHash === bondedHash) {
                    const cosignature =
                        SymbolFacade.cosignTransactionHash(
                            accountBKeyPair,
                            new Hash256(bondedHash), true);
                    const cosignaturePayload = JSON.stringify({
                        version: cosignature.version.toString(),
                        signerPublicKey:
                            cosignature.signerPublicKey.toString(),
                        signature: cosignature.signature.toString(),
                        parentHash: cosignature.parentHash.toString()
                    });
                    fetch(`${NODE_URL}/transactions/cosignature`, {
                        method: 'PUT',
                        headers: {'Content-Type': 'application/json'},
                        body: cosignaturePayload
                    }).then(() => console.log(
                        '[Account B] Submitted cosignature'))
                    .catch((err) => console.error(
                        'Cosignature failed:', err));
                }

            } else if (name === 'confirmedAdded') {
                const messageHash = message.data.meta.hash;
                console.log(`confirmedAdded: hash=`
                    + `${messageHash.substring(0, 16)}...`);
                if (messageHash === bondedHash) {
                    console.log('Transaction '
                        + bondedHash.substring(0, 16) + '... confirmed');
                    resolve();
                }

            } else {
                const messageHash = message.data.meta.hash;
                console.log(
                    `${name}: hash=${messageHash.substring(0, 16)}...`);
            }
        });
    });

アカウント B は受信メッセージをリスニングし、チャネルごとに振り分けます。 メッセージのスキーマは、 cosignature メッセージを除き、 通常のトランザクションフロー チュートリアルと同じです。 cosignature メッセージは CosignatureDTO スキーマに従い、他のチャネルで使用される meta.hash フィールドは含まれません。

重要なアクションは partialAdded で発生します。ハッシュが期待されるアグリゲートと一致した場合、アカウント B は detached パラメータを true に設定した を使用してトランザクションに連署し、 /transactions/cosignature PUT に連署をアナウンスします。 より深い検証を行うために、アカウント B は /transactions/partial/{transactionId} GET から完全なトランザクションを取得し、内容を検査してから連署するかどうかを決定することができます。

成功したボンデッドアグリゲートの期待されるメッセージシーケンスは、テキストブックの トランザクションのライフサイクル セクションで説明されています。

  1. partialAdded: ボンデッドアグリゲートがパーシャル(部分的)キャッシュに入り、連署を待ちます。
  2. cosignature: アカウント B からの連署が追加されます。
  3. unconfirmedAdded: 完全に署名されたトランザクションが [未承認トランザクションプール] (default: 未承認トランザクションプール) に入ります。
  4. partialRemoved: トランザクションが partial 状態を抜けます。
  5. unconfirmedRemoved: トランザクションが未承認プールを抜けます。
  6. confirmedAdded: トランザクションがブロック内で承認されます。

アカウント B: チャネルのサブスクライブ解除⚓︎

        # Unsubscribe before closing
        for channel in channels:
            await websocket.send(json.dumps({
                'uid': uid,
                'unsubscribe': channel
            }))
        print('[Account B] Unsubscribed from all channels')
    // Unsubscribe before closing
    for (const channel of channels) {
        websocket.send(JSON.stringify({uid, unsubscribe: channel}));
    }
    console.log('[Account B] Unsubscribed from all channels');
    websocket.close();

承認後、アカウント B は接続を閉じる前に、すべてのチャネルのサブスクライブ解除メッセージを送信します。

出力⚓︎

Using node https://reference.symboltest.net:3001
Account A: TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I
Account B: TCWYXKVYBMO4NBCUF3AXKJMXCGVSYQOS7ZG2TLI
[Account A] Bonded aggregate hash: 1B48628680C99C5F...
[Account A] Announced hash lock A9CD8DE6E4CD1EDF...
Hash lock confirmed
[Account B] Connected to wss://reference.symboltest.net:3001/ws with uid bPpqa5v2vhja9J5wUTjP8OIc5Io=
[Account B] Subscribed to partialAdded channel
[Account B] Subscribed to partialRemoved channel
[Account B] Subscribed to cosignature channel
[Account B] Subscribed to unconfirmedAdded channel
[Account B] Subscribed to unconfirmedRemoved channel
[Account B] Subscribed to confirmedAdded channel
[Account B] Subscribed to status channel
[Account A] Announced bonded 1B48628680C99C5F...
partialAdded: hash=1B48628680C99C5F...
[Account B] Submitted cosignature
cosignature: signer=D04AB232742BB4AB...
unconfirmedAdded: hash=1B48628680C99C5F...
partialRemoved: hash=1B48628680C99C5F...
unconfirmedRemoved: hash=1B48628680C99C5F...
confirmedAdded: hash=1B48628680C99C5F...
Transaction 1B48628680C99C5F... confirmed
[Account B] Unsubscribed from all channels

出力のポイント:

  • アカウント (2-3行目): アカウント A(開始者)とアカウント B(連署者)のアドレス。
  • ハッシュロック (6行目): ボンデッドアグリゲートハッシュが計算され、ハッシュロックがアナウンスされ、その承認が WebSocket 経由で受信されます。
  • 接続 (7行目): WebSocket 接続が確立され、サーバーは一意の uid を返します。
  • サブスクリプション (8-14行目): ( status を含む)7つすべてのボンデッドトランザクションチャネルがサブスクライブされます。
  • アナウンス (15行目): ボンデッドアグリゲートが /transactions/partial にアナウンスされます。
  • 連署 (16-18行目): アグリゲートが partialAdded に入り、アカウント B が連署を送信し、 cosignature チャネルがそれを受信したことを確認します。
  • 承認 (19-22行目): 完全に署名されたトランザクションが未承認プールに入り( unconfirmedAdded )、 partial 状態を抜け( partialRemoved )、 unconfirmedRemoved を経て、最後に confirmedAdded になります。

結論⚓︎

このチュートリアルでは、以下の方法を説明しました。

ステップ 関連ドキュメント
partialAdded をサブスクライブする partialAdded/{address} WS
partialRemoved をサブスクライブする partialRemoved/{address} WS
cosignature をサブスクライブする cosignature/{address} WS
トランザクションメッセージを処理する TransactionInfoDTO
連署メッセージを処理する CosignatureDTO
partialAdded で連署を送信する
/transactions/cosignature PUT