Skip to content

Listening to Bonded Transaction Flow⚓︎

Bonded aggregate transactions follow a richer lifecycle than regular transactions. After being announced, they enter a partial state where the network receives cosignatures from all required participants. Only after all cosignatures arrive does the transaction move through the standard unconfirmed and confirmed states.

This tutorial recreates the asset swap from the Bonded Aggregate Transaction tutorial, but monitors the full bonded lifecycle using WebSocket channels instead of polling.

Account A builds and announces the aggregate, while Account B subscribes to WebSocket channels, cosigns, and waits for confirmation.

Prerequisites⚓︎

Before you start, make sure to set up your development environment. See Setting Up a Development Environment.

Additionally, install the language-specific WebSocket library:

Install the websockets library:

pip install websockets

This tutorial uses the native WebSocket API available in Node.js 22 or later. No additional packages are required.

You also need two accounts with XYM and one custom mosaic to complete the swap. Although pre-funded accounts are provided for convenience, they are not maintained and may run out of funds.

To use your own accounts, complete the following steps:

Full Code⚓︎

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

A bonded aggregate transaction involves two distinct roles: an initiator (Account A) that builds, signs, and announces the aggregate, and one or more cosigners (Account B and any additional cosigners) that monitor WebSocket channels and cosign after verifying the transaction.

In practice, each role runs as a separate program on a separate machine, and all cosigners must already be listening before the initiator submits the bonded aggregate. This tutorial combines both roles in a single script for simplicity.

Code Explanation⚓︎

Account A: Setting Up Accounts⚓︎

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());

This example includes both private keys in one script for simplicity. In practice, each party signs on their own machine. Account A only needs Account B's public key to build the aggregate, because B's public key is required to set B as the signer of an embedded transaction and to derive B's address.

The ACCOUNT_A_PRIVATE_KEY and ACCOUNT_B_PRIVATE_KEY environment variables set the keys for each account. If not provided, test keys are used by default. If using your own keys, ensure Account A has XYM and Account B holds a custom mosaic for the swap. The addresses are derived from the public keys using the facade's network configuration.

Account A: Building the Aggregate and Announcing the Hash Lock⚓︎

    # [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();

Account A creates the bonded aggregate that swaps 10 XYM for 1 custom mosaic from Account B, signs it, and announces the required hash lock, following the same pattern described in the Bonded Aggregate Transaction tutorial.

The only difference is that instead of polling /transactionStatus/{hash} GET to confirm the hash lock, this tutorial uses WebSockets, following the same approach described in the Listening to Transaction Flow tutorial.

Account B: Connecting and Subscribing to Channels⚓︎

    # [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`);
    }

The snippet uses the NODE_URL environment variable to set the Symbol API node. If no value is provided, a default one is used. The WebSocket URL is derived from NODE_URL by replacing the HTTP protocol with the WebSocket protocol and appending /ws.

Account B opens a WebSocket connection and subscribes to channels scoped to its own address to monitor the bonded transaction lifecycle. Since Account B is a participant in the aggregate, the node delivers all lifecycle events for the transaction to Account B's address. In addition to the channels used for regular transactions, bonded aggregates use extra channels:

  • partialAdded/{address} WS: Notifies when a bonded aggregate enters the partial state, waiting for cosignatures.
  • partialRemoved/{address} WS: Notifies when a bonded aggregate leaves the partial state (either all cosignatures were collected or the deadline expired).
  • cosignature/{address} WS: Notifies when a cosignature is added to a partial transaction.

Account A: Announcing the Bonded Aggregate⚓︎

        # [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;

Once Account B is subscribed, Account A announces the bonded aggregate to /transactions/partial PUT (not the regular /transactions PUT endpoint).

Account B: Handling WebSocket Messages and Cosigning⚓︎

        # [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)}...`);
            }
        });
    });

Account B listens for incoming messages and dispatches them by channel. The message schemas are the same as in the regular transaction flow tutorial, except for cosignature messages, which follow the CosignatureDTO schema and do not include the meta.hash field used by other channels.

The key action happens on partialAdded: when the hash matches the expected aggregate, Account B cosigns the transaction using with the detached parameter set to true, and announces the cosignature to /transactions/cosignature PUT. For deeper verification, Account B can fetch the full transaction from /transactions/partial/{transactionId} GET and inspect its contents before deciding whether to cosign.

The expected message sequence for a successful bonded aggregate is described in the Transaction Lifecycle section:

  1. partialAdded: The bonded aggregate enters the partial cache, waiting for cosignatures.
  2. cosignature: A cosignature from Account B is added.
  3. unconfirmedAdded: The fully signed transaction enters the unconfirmed pool.
  4. partialRemoved: The transaction leaves the partial state.
  5. unconfirmedRemoved: The transaction leaves the unconfirmed pool.
  6. confirmedAdded: The transaction is confirmed in a block.

Account B: Unsubscribing from Channels⚓︎

        # 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();

After confirmation, Account B sends unsubscribe messages for all channels before closing the connection.

Output⚓︎

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

The output shows:

  • Accounts (lines 2-3): The addresses for Account A (initiator) and Account B (cosigner).
  • Hash lock (lines 6): The bonded aggregate hash is computed, the hash lock is announced, and its confirmation is received via WebSocket.
  • Connection (line 7): The WebSocket connection is established and the server returns a unique uid.
  • Subscriptions (lines 8-14): All seven bonded transaction channels are subscribed (including status).
  • Announcement (line 15): The bonded aggregate is announced to /transactions/partial.
  • Cosigning (lines 16-18): The aggregate enters partialAdded, Account B submits a cosignature, and the cosignature channel confirms it was received.
  • Confirmation (lines 19-22): The fully signed transaction enters the unconfirmed pool (unconfirmedAdded), leaves the partial state (partialRemoved), moves through unconfirmedRemoved, and is finally confirmedAdded.

Conclusion⚓︎

This tutorial showed how to:

Step Related documentation
Subscribe to partialAdded partialAdded/{address} WS
Subscribe to partialRemoved partialRemoved/{address} WS
Subscribe to cosignature cosignature/{address} WS
Handle transaction messages TransactionInfoDTO
Handle cosignature messages CosignatureDTO
Submit cosignatures on partialAdded
/transactions/cosignature PUT