Skip to content

Listening to Bonded Transaction Flow⚓︎

ADVANCED

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.

This tutorial uses the Tyrus WebSocket client, which is loaded by the JBang dependency line in the snippet.

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⚓︎

The following is the complete code listing for this tutorial. A detailed, step-by-step explanation follows in the next section.

import asyncio
import json
import os
import urllib.request

from symbolchain.CryptoTypes import PrivateKey
from symbolchain.facade.SymbolFacade import SymbolFacade
from symbolchain.symbol.IdGenerator import generate_mosaic_alias_id
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}')


def announce_transaction(payload, endpoint, label):
    request = urllib.request.Request(
        f'{NODE_URL}{endpoint}',
        data=payload.encode(),
        headers={'Content-Type': 'application/json'},
        method='PUT'
    )
    with urllib.request.urlopen(request) as response:
        response.read()
    print(label)


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 recommended fees
    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.create_embedded_transaction_from_descriptor(
            {
                'type': 'transfer_transaction_v1',
                'recipient_address': account_b_address,
                'mosaics': [{
                    'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
                    'amount': 10_000_000
                }]
            },
            account_a_key_pair.public_key))

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

    # Build the bonded aggregate transaction
    embedded_txs = [embedded_tx_1, embedded_tx_2]
    bonded_tx = facade.create_transaction_from_descriptor(
        {
            'type': 'aggregate_bonded_transaction_v3',
            'transactions_hash': facade.hash_embedded_transactions(
                embedded_txs),
            'transactions': embedded_txs
        },
        account_a_key_pair.public_key,
        fee_multiplier,
        2 * 60 * 60,
        1)

    # 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.create_transaction_from_descriptor(
        {
            'type': 'hash_lock_transaction_v1',
            'mosaic': {
                'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
                'amount': 10_000_000
            },
            'duration': 100,
            'hash': bonded_hash
        },
        account_a_key_pair.public_key,
        fee_multiplier,
        2 * 60 * 60)
    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
        announce_transaction(
            hash_lock_payload, '/transactions',
            f'[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 RuntimeError(
                        '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
        announce_transaction(
            bonded_payload, '/transactions/partial',
            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 RuntimeError(
                        '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)
                    })
                    announce_transaction(
                        cosignature_payload,
                        '/transactions/cosignature',
                        '[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 {
    SymbolFacade,
    descriptors,
    generateMosaicAliasId,
    models
} 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}`);

async function announceTransaction(payload, endpoint, label) {
    await fetch(`${NODE_URL}${endpoint}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: payload
    });
    console.log(label);
}


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 recommended fees
    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.createEmbeddedTransactionFromTypedDescriptor(
            new descriptors.TransferTransactionV1Descriptor(
                accountBAddress,
                [
                    new descriptors.UnresolvedMosaicDescriptor(
                        generateMosaicAliasId('symbol.xym'),
                        new models.Amount(10_000_000n))
                ],
                undefined),
            accountAKeyPair.publicKey);

    const customMosaicId = 0x6D1314BE751B62C2n;
    const embeddedTx2 =
        facade.createEmbeddedTransactionFromTypedDescriptor(
            new descriptors.TransferTransactionV1Descriptor(
                accountAAddress,
                [
                    new descriptors.UnresolvedMosaicDescriptor(
                        customMosaicId,
                        new models.Amount(1n))
                ],
                undefined),
            accountBKeyPair.publicKey);

    // Build the bonded aggregate transaction
    const embeddedTxs = [embeddedTx1, embeddedTx2];
    const bondedTx = facade.createTransactionFromTypedDescriptor(
        new descriptors.AggregateBondedTransactionV3Descriptor(
            facade.static.hashEmbeddedTransactions(embeddedTxs),
            embeddedTxs,
            undefined),
        accountAKeyPair.publicKey,
        feeMultiplier,
        2 * 60 * 60,
        1);

    // 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.createTransactionFromTypedDescriptor(
        new descriptors.HashLockTransactionV1Descriptor(
            new descriptors.UnresolvedMosaicDescriptor(
                generateMosaicAliasId('symbol.xym'),
                new models.Amount(10_000_000n)),
            new models.BlockDuration(100n),
            new Hash256(bondedHash)),
        accountAKeyPair.publicKey,
        feeMultiplier,
        2 * 60 * 60);
    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 announceTransaction(
        hashLockPayload, '/transactions',
        '[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 ('confirmedAdded' === name &&
                message.data.meta.hash === hashLockHash) {
                console.log('Hash lock confirmed');
                resolve();
            }

            if ('status' === name &&
                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 ('cosignature' === name) {
                const signer = message.data.signerPublicKey;
                console.log(
                    `cosignature: signer=${signer.substring(0, 16)}...`);
            } else if ('status' === name) {
                const statusHash = message.data.hash;
                console.log(
                    `status: hash=${statusHash.substring(0, 16)}...`);
                if (statusHash === bondedHash) {
                    reject(new Error(
                        `Transaction failed: ${message.data.code}`));
                }
            } else if ('partialAdded' === name) {
                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()
                    });
                    announceTransaction(
                        cosignaturePayload,
                        '/transactions/cosignature',
                        '[Account B] Submitted cosignature')
                        .catch(err => console.error(
                            'Cosignature failed:', err));
                }
            } else if ('confirmedAdded' === name) {
                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 announceTransaction(
        bondedPayload, '/transactions/partial',
        '[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

//JAVA 21+
//DEPS org.symbol:symbol-sdk:3.3.1
//DEPS org.glassfish.tyrus.bundles:tyrus-standalone-client:2.2.0

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.util.List;
import java.util.concurrent.CompletableFuture;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.websocket.ClientEndpoint;
import jakarta.websocket.ContainerProvider;
import jakarta.websocket.OnMessage;
import jakarta.websocket.RemoteEndpoint;
import jakarta.websocket.Session;
import jakarta.websocket.WebSocketContainer;

import org.symbol.sdk.CryptoTypes;
import org.symbol.sdk.facade.SymbolFacade;
import org.symbol.sdk.symbol.Address;
import org.symbol.sdk.symbol.IdGenerator;
import org.symbol.sdk.symbol.KeyPair;
import org.symbol.sdk.symbol.SymbolTransactionFactory;
import org.symbol.sdk.symbol.descriptors.*;
import org.symbol.sdk.symbol.models.*;

@ClientEndpoint
public final class ListenBondedTransactionFlow {
    private static final ObjectMapper JSON_MAPPER = new ObjectMapper();

    private static final HttpClient HTTP_CLIENT =
        HttpClient.newHttpClient();

    private CompletableFuture<String> uidFuture;

    private final CompletableFuture<Void> hashLockConfirmed =
        new CompletableFuture<>();

    private final CompletableFuture<Void> confirmed =
        new CompletableFuture<>();

    private final String nodeUrl = System.getenv().getOrDefault(
        "NODE_URL", "https://reference.symboltest.net:3001");

    private final String wsUrl = nodeUrl.replaceFirst("http", "ws")
        + "/ws";

    private final SymbolFacade facade = new SymbolFacade("testnet");

    private KeyPair accountBKeyPair;

    private String bondedHash;

    private String hashLockHash;

    public static void main(final String[] args) {
        try {
            new ListenBondedTransactionFlow().run();
        } catch (final Exception ex) {
            System.out.println(null == ex.getMessage()
                ? ex.toString()
                : ex.getMessage());
        }
    }

    private void run() throws Exception {
        System.out.printf("Using node %s%n", nodeUrl);

        final String accountAPrivateKey = System.getenv().getOrDefault(
            "ACCOUNT_A_PRIVATE_KEY", "0".repeat(64));
        final String accountBPrivateKey = System.getenv().getOrDefault(
            "ACCOUNT_B_PRIVATE_KEY", "1".repeat(64));

        final KeyPair accountAKeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(accountAPrivateKey));
        accountBKeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(accountBPrivateKey));
        final Address accountAAddress = facade.network.publicKeyToAddress(
            accountAKeyPair.getPublicKey());
        final Address accountBAddress = facade.network.publicKeyToAddress(
            accountBKeyPair.getPublicKey());
        System.out.printf("Account A: %s%n", accountAAddress);
        System.out.printf("Account B: %s%n", accountBAddress);

        // Fetch recommended fees
        final JsonNode feeJSON = getJson("/network/fees/transaction");
        final long feeMultiplier = Math.max(
            feeJSON.get("medianFeeMultiplier").asLong(),
            feeJSON.get("minFeeMultiplier").asLong());

        // [Account A] Build embedded transactions for the swap
        final EmbeddedTransaction embeddedTx1 =
            facade.createEmbeddedTransactionFromTypedDescriptor(
                new TransferTransactionV1Descriptor(
                    accountBAddress,
                    List.of(new UnresolvedMosaicDescriptor(
                        new UnresolvedMosaicId(
                            IdGenerator.generateMosaicAliasId(
                                "symbol.xym")),
                        new Amount(10_000_000))),
                    null),
                accountAKeyPair.getPublicKey());

        final long customMosaicId = 0x6D1314BE751B62C2L;
        final EmbeddedTransaction embeddedTx2 =
            facade.createEmbeddedTransactionFromTypedDescriptor(
                new TransferTransactionV1Descriptor(
                    accountAAddress,
                    List.of(new UnresolvedMosaicDescriptor(
                        new UnresolvedMosaicId(customMosaicId),
                        new Amount(1))),
                    null),
                accountBKeyPair.getPublicKey());

        // Build the bonded aggregate transaction
        final List<EmbeddedTransaction> embeddedTxs =
            List.of(embeddedTx1, embeddedTx2);
        final Transaction bondedTx =
            facade.createTransactionFromTypedDescriptor(
                new AggregateBondedTransactionV3Descriptor(
                    SymbolFacade.hashEmbeddedTransactions(embeddedTxs),
                    embeddedTxs,
                    null),
                accountAKeyPair.getPublicKey(),
                feeMultiplier,
                2 * 60 * 60,
                1);

        // Sign the bonded aggregate
        final CryptoTypes.Signature bondedSignature =
            facade.signTransaction(accountAKeyPair, bondedTx);
        final String bondedPayload = SymbolTransactionFactory
            .attachSignature(bondedTx, bondedSignature);
        bondedHash = facade.hashTransaction(bondedTx).toString();
        System.out.println("[Account A] Bonded aggregate hash: "
            + bondedHash.substring(0, 16) + "...");

        // Create the hash lock transaction
        final Transaction hashLock =
            facade.createTransactionFromTypedDescriptor(
                new HashLockTransactionV1Descriptor(
                    new UnresolvedMosaicDescriptor(
                        new UnresolvedMosaicId(
                            IdGenerator.generateMosaicAliasId(
                                "symbol.xym")),
                        new Amount(10_000_000)),
                    new BlockDuration(100),
                    new CryptoTypes.Hash256(bondedHash)),
                accountAKeyPair.getPublicKey(),
                feeMultiplier,
                2 * 60 * 60);
        final CryptoTypes.Signature hashLockSignature =
            facade.signTransaction(accountAKeyPair, hashLock);
        final String hashLockPayload = SymbolTransactionFactory
            .attachSignature(hashLock, hashLockSignature);
        hashLockHash = facade.hashTransaction(hashLock).toString();

        // Confirm hash lock via WebSocket
        uidFuture = new CompletableFuture<>();
        final WebSocketContainer container =
            ContainerProvider.getWebSocketContainer();
        final Session lockSession = container.connectToServer(
            this, URI.create(wsUrl));
        final RemoteEndpoint.Basic lockRemote =
            lockSession.getBasicRemote();
        final String lockUid = uidFuture.join();

        final String addressA = accountAAddress.toString();
        final List<String> lockChannels = List.of(
            "confirmedAdded/" + addressA,
            "status/" + addressA);
        for (final String channel : lockChannels)
            lockRemote.sendText(JSON_MAPPER.createObjectNode()
                .put("uid", lockUid).put("subscribe", channel)
                .toString());

        // Announce hash lock
        announceTransaction(
            hashLockPayload, "/transactions",
            "[Account A] Announced hash lock "
            + hashLockHash.substring(0, 16) + "...");

        // Wait for hash lock confirmation
        hashLockConfirmed.join();

        for (final String channel : lockChannels)
            lockRemote.sendText(JSON_MAPPER.createObjectNode()
                .put("uid", lockUid).put("unsubscribe", channel)
                .toString());
        lockSession.close();

        // [Account B] Connect to WebSocket for bonded flow
        uidFuture = new CompletableFuture<>();
        final Session session = container.connectToServer(
            this, URI.create(wsUrl));
        final RemoteEndpoint.Basic remote = session.getBasicRemote();
        final String uid = uidFuture.join();
        System.out.printf("[Account B] Connected to %s with uid %s%n",
            wsUrl, uid);

        // Subscribe to bonded transaction channels
        final String addressB = accountBAddress.toString();
        final List<String> channels = List.of(
            "partialAdded/" + addressB,
            "partialRemoved/" + addressB,
            "cosignature/" + addressB,
            "unconfirmedAdded/" + addressB,
            "unconfirmedRemoved/" + addressB,
            "confirmedAdded/" + addressB,
            "status/" + addressB);
        for (final String channel : channels) {
            remote.sendText(JSON_MAPPER.createObjectNode()
                .put("uid", uid).put("subscribe", channel)
                .toString());
            System.out.printf(
                "[Account B] Subscribed to %s channel%n",
                channel.split("/")[0]);
        }

        // [Account A] Announce bonded aggregate
        announceTransaction(
            bondedPayload, "/transactions/partial",
            "[Account A] Announced bonded "
            + bondedHash.substring(0, 16) + "...");

        // Wait for confirmation via WebSocket
        confirmed.join();
        // Unsubscribe before closing
        for (final String channel : channels)
            remote.sendText(JSON_MAPPER.createObjectNode()
                .put("uid", uid).put("unsubscribe", channel)
                .toString());
        System.out.println("[Account B] Unsubscribed from all channels");
        session.close();
    }

    private JsonNode getJson(final String path)
        throws IOException, InterruptedException {
        final HttpRequest request = HttpRequest.newBuilder(
            URI.create(nodeUrl + path)).GET().build();
        final HttpResponse<String> response = HTTP_CLIENT.send(
            request, BodyHandlers.ofString());
        return JSON_MAPPER.readTree(response.body());
    }

    private void announceTransaction(
        final String payload,
        final String endpoint,
        final String label
    )
        throws IOException, InterruptedException {
        final HttpRequest request = HttpRequest.newBuilder(
            URI.create(nodeUrl + endpoint))
            .header("Content-Type", "application/json")
            .PUT(HttpRequest.BodyPublishers.ofString(payload))
            .build();
        HTTP_CLIENT.send(request, BodyHandlers.ofString());
        System.out.println(label);
    }

    // [Account B] Listen for bonded transaction flow
    @OnMessage
    public void onMessage(final String payload) throws Exception {
        final JsonNode message = JSON_MAPPER.readTree(payload);

        // Special case for the initial handshake message
        if (message.has("uid")) {
            uidFuture.complete(message.get("uid").asText());
            return;
        }

        final String topic = message.get("topic").asText();
        final String name = topic.split("/")[0];

        if ("cosignature".equals(name)) {
            final String signer = message.get("data")
                .get("signerPublicKey").asText();
            System.out.println("cosignature: signer="
                + signer.substring(0, 16) + "...");
        } else if ("status".equals(name)) {
            handleStatus(message);
        } else if ("partialAdded".equals(name)) {
            handlePartialAdded(message);
        } else if ("confirmedAdded".equals(name)) {
            handleConfirmedAdded(message);
        } else {
            final String messageHash = message.get("data")
                .get("meta").get("hash").asText();
            System.out.printf("%s: hash=%s...%n",
                name, messageHash.substring(0, 16));
        }
    }

    private void handleStatus(final JsonNode message) {
        final String statusHash = message.get("data").get("hash").asText();
        System.out.println("status: hash="
            + statusHash.substring(0, 16) + "...");
        if (statusHash.equals(hashLockHash))
            hashLockConfirmed.completeExceptionally(new IOException(
                "Hash lock failed: "
                + message.get("data").get("code").asText()));
        if (statusHash.equals(bondedHash))
            confirmed.completeExceptionally(new IOException(
                "Transaction failed: "
                + message.get("data").get("code").asText()));
    }

    private void handlePartialAdded(final JsonNode message)
        throws IOException, InterruptedException {
        final String messageHash = message.get("data")
            .get("meta").get("hash").asText();
        System.out.println("partialAdded: hash="
            + messageHash.substring(0, 16) + "...");
        if (messageHash.equals(bondedHash)) {
            final DetachedCosignature cosignature =
                SymbolFacade.cosignTransactionHashDetached(
                    accountBKeyPair,
                    new CryptoTypes.Hash256(bondedHash));
            final String cosignaturePayload = JSON_MAPPER
                .writeValueAsString(cosignature.toJson());
            announceTransaction(
                cosignaturePayload, "/transactions/cosignature",
                "[Account B] Submitted cosignature");
        }
    }

    private void handleConfirmedAdded(final JsonNode message) {
        final String messageHash = message.get("data")
            .get("meta").get("hash").asText();
        System.out.println("confirmedAdded: hash="
            + messageHash.substring(0, 16) + "...");
        if (messageHash.equals(hashLockHash)) {
            System.out.println("Hash lock confirmed");
            hashLockConfirmed.complete(null);
        }
        if (messageHash.equals(bondedHash)) {
            System.out.println("Transaction "
                + bondedHash.substring(0, 16) + "... confirmed");
            confirmed.complete(null);
        }
    }

}

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⚓︎

The code 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 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());
        final String accountAPrivateKey = System.getenv().getOrDefault(
            "ACCOUNT_A_PRIVATE_KEY", "0".repeat(64));
        final String accountBPrivateKey = System.getenv().getOrDefault(
            "ACCOUNT_B_PRIVATE_KEY", "1".repeat(64));

        final KeyPair accountAKeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(accountAPrivateKey));
        accountBKeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(accountBPrivateKey));
        final Address accountAAddress = facade.network.publicKeyToAddress(
            accountAKeyPair.getPublicKey());
        final Address accountBAddress = facade.network.publicKeyToAddress(
            accountBKeyPair.getPublicKey());
        System.out.printf("Account A: %s%n", accountAAddress);
        System.out.printf("Account B: %s%n", accountBAddress);

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.create_embedded_transaction_from_descriptor(
            {
                'type': 'transfer_transaction_v1',
                'recipient_address': account_b_address,
                'mosaics': [{
                    'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
                    'amount': 10_000_000
                }]
            },
            account_a_key_pair.public_key))

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

    # Build the bonded aggregate transaction
    embedded_txs = [embedded_tx_1, embedded_tx_2]
    bonded_tx = facade.create_transaction_from_descriptor(
        {
            'type': 'aggregate_bonded_transaction_v3',
            'transactions_hash': facade.hash_embedded_transactions(
                embedded_txs),
            'transactions': embedded_txs
        },
        account_a_key_pair.public_key,
        fee_multiplier,
        2 * 60 * 60,
        1)

    # 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.create_transaction_from_descriptor(
        {
            'type': 'hash_lock_transaction_v1',
            'mosaic': {
                'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
                'amount': 10_000_000
            },
            'duration': 100,
            'hash': bonded_hash
        },
        account_a_key_pair.public_key,
        fee_multiplier,
        2 * 60 * 60)
    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
        announce_transaction(
            hash_lock_payload, '/transactions',
            f'[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 RuntimeError(
                        '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.createEmbeddedTransactionFromTypedDescriptor(
            new descriptors.TransferTransactionV1Descriptor(
                accountBAddress,
                [
                    new descriptors.UnresolvedMosaicDescriptor(
                        generateMosaicAliasId('symbol.xym'),
                        new models.Amount(10_000_000n))
                ],
                undefined),
            accountAKeyPair.publicKey);

    const customMosaicId = 0x6D1314BE751B62C2n;
    const embeddedTx2 =
        facade.createEmbeddedTransactionFromTypedDescriptor(
            new descriptors.TransferTransactionV1Descriptor(
                accountAAddress,
                [
                    new descriptors.UnresolvedMosaicDescriptor(
                        customMosaicId,
                        new models.Amount(1n))
                ],
                undefined),
            accountBKeyPair.publicKey);

    // Build the bonded aggregate transaction
    const embeddedTxs = [embeddedTx1, embeddedTx2];
    const bondedTx = facade.createTransactionFromTypedDescriptor(
        new descriptors.AggregateBondedTransactionV3Descriptor(
            facade.static.hashEmbeddedTransactions(embeddedTxs),
            embeddedTxs,
            undefined),
        accountAKeyPair.publicKey,
        feeMultiplier,
        2 * 60 * 60,
        1);

    // 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.createTransactionFromTypedDescriptor(
        new descriptors.HashLockTransactionV1Descriptor(
            new descriptors.UnresolvedMosaicDescriptor(
                generateMosaicAliasId('symbol.xym'),
                new models.Amount(10_000_000n)),
            new models.BlockDuration(100n),
            new Hash256(bondedHash)),
        accountAKeyPair.publicKey,
        feeMultiplier,
        2 * 60 * 60);
    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 announceTransaction(
        hashLockPayload, '/transactions',
        '[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 ('confirmedAdded' === name &&
                message.data.meta.hash === hashLockHash) {
                console.log('Hash lock confirmed');
                resolve();
            }

            if ('status' === name &&
                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] Build embedded transactions for the swap
        final EmbeddedTransaction embeddedTx1 =
            facade.createEmbeddedTransactionFromTypedDescriptor(
                new TransferTransactionV1Descriptor(
                    accountBAddress,
                    List.of(new UnresolvedMosaicDescriptor(
                        new UnresolvedMosaicId(
                            IdGenerator.generateMosaicAliasId(
                                "symbol.xym")),
                        new Amount(10_000_000))),
                    null),
                accountAKeyPair.getPublicKey());

        final long customMosaicId = 0x6D1314BE751B62C2L;
        final EmbeddedTransaction embeddedTx2 =
            facade.createEmbeddedTransactionFromTypedDescriptor(
                new TransferTransactionV1Descriptor(
                    accountAAddress,
                    List.of(new UnresolvedMosaicDescriptor(
                        new UnresolvedMosaicId(customMosaicId),
                        new Amount(1))),
                    null),
                accountBKeyPair.getPublicKey());

        // Build the bonded aggregate transaction
        final List<EmbeddedTransaction> embeddedTxs =
            List.of(embeddedTx1, embeddedTx2);
        final Transaction bondedTx =
            facade.createTransactionFromTypedDescriptor(
                new AggregateBondedTransactionV3Descriptor(
                    SymbolFacade.hashEmbeddedTransactions(embeddedTxs),
                    embeddedTxs,
                    null),
                accountAKeyPair.getPublicKey(),
                feeMultiplier,
                2 * 60 * 60,
                1);

        // Sign the bonded aggregate
        final CryptoTypes.Signature bondedSignature =
            facade.signTransaction(accountAKeyPair, bondedTx);
        final String bondedPayload = SymbolTransactionFactory
            .attachSignature(bondedTx, bondedSignature);
        bondedHash = facade.hashTransaction(bondedTx).toString();
        System.out.println("[Account A] Bonded aggregate hash: "
            + bondedHash.substring(0, 16) + "...");

        // Create the hash lock transaction
        final Transaction hashLock =
            facade.createTransactionFromTypedDescriptor(
                new HashLockTransactionV1Descriptor(
                    new UnresolvedMosaicDescriptor(
                        new UnresolvedMosaicId(
                            IdGenerator.generateMosaicAliasId(
                                "symbol.xym")),
                        new Amount(10_000_000)),
                    new BlockDuration(100),
                    new CryptoTypes.Hash256(bondedHash)),
                accountAKeyPair.getPublicKey(),
                feeMultiplier,
                2 * 60 * 60);
        final CryptoTypes.Signature hashLockSignature =
            facade.signTransaction(accountAKeyPair, hashLock);
        final String hashLockPayload = SymbolTransactionFactory
            .attachSignature(hashLock, hashLockSignature);
        hashLockHash = facade.hashTransaction(hashLock).toString();

        // Confirm hash lock via WebSocket
        uidFuture = new CompletableFuture<>();
        final WebSocketContainer container =
            ContainerProvider.getWebSocketContainer();
        final Session lockSession = container.connectToServer(
            this, URI.create(wsUrl));
        final RemoteEndpoint.Basic lockRemote =
            lockSession.getBasicRemote();
        final String lockUid = uidFuture.join();

        final String addressA = accountAAddress.toString();
        final List<String> lockChannels = List.of(
            "confirmedAdded/" + addressA,
            "status/" + addressA);
        for (final String channel : lockChannels)
            lockRemote.sendText(JSON_MAPPER.createObjectNode()
                .put("uid", lockUid).put("subscribe", channel)
                .toString());

        // Announce hash lock
        announceTransaction(
            hashLockPayload, "/transactions",
            "[Account A] Announced hash lock "
            + hashLockHash.substring(0, 16) + "...");

        // Wait for hash lock confirmation
        hashLockConfirmed.join();

        for (final String channel : lockChannels)
            lockRemote.sendText(JSON_MAPPER.createObjectNode()
                .put("uid", lockUid).put("unsubscribe", channel)
                .toString());
        lockSession.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`);
    }
        // [Account B] Connect to WebSocket for bonded flow
        uidFuture = new CompletableFuture<>();
        final Session session = container.connectToServer(
            this, URI.create(wsUrl));
        final RemoteEndpoint.Basic remote = session.getBasicRemote();
        final String uid = uidFuture.join();
        System.out.printf("[Account B] Connected to %s with uid %s%n",
            wsUrl, uid);

        // Subscribe to bonded transaction channels
        final String addressB = accountBAddress.toString();
        final List<String> channels = List.of(
            "partialAdded/" + addressB,
            "partialRemoved/" + addressB,
            "cosignature/" + addressB,
            "unconfirmedAdded/" + addressB,
            "unconfirmedRemoved/" + addressB,
            "confirmedAdded/" + addressB,
            "status/" + addressB);
        for (final String channel : channels) {
            remote.sendText(JSON_MAPPER.createObjectNode()
                .put("uid", uid).put("subscribe", channel)
                .toString());
            System.out.printf(
                "[Account B] Subscribed to %s channel%n",
                channel.split("/")[0]);
        }

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
        announce_transaction(
            bonded_payload, '/transactions/partial',
            f'[Account A] Announced bonded {str(bonded_hash)[:16]}...'
        )
    // [Account A] Announce bonded aggregate
    await announceTransaction(
        bondedPayload, '/transactions/partial',
        '[Account A] Announced bonded ' +
        `${bondedHash.substring(0, 16)}...`);
        // [Account A] Announce bonded aggregate
        announceTransaction(
            bondedPayload, "/transactions/partial",
            "[Account A] Announced bonded "
            + bondedHash.substring(0, 16) + "...");

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 RuntimeError(
                        '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)
                    })
                    announce_transaction(
                        cosignature_payload,
                        '/transactions/cosignature',
                        '[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 ('cosignature' === name) {
                const signer = message.data.signerPublicKey;
                console.log(
                    `cosignature: signer=${signer.substring(0, 16)}...`);
            } else if ('status' === name) {
                const statusHash = message.data.hash;
                console.log(
                    `status: hash=${statusHash.substring(0, 16)}...`);
                if (statusHash === bondedHash) {
                    reject(new Error(
                        `Transaction failed: ${message.data.code}`));
                }
            } else if ('partialAdded' === name) {
                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()
                    });
                    announceTransaction(
                        cosignaturePayload,
                        '/transactions/cosignature',
                        '[Account B] Submitted cosignature')
                        .catch(err => console.error(
                            'Cosignature failed:', err));
                }
            } else if ('confirmedAdded' === name) {
                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] Listen for bonded transaction flow
    @OnMessage
    public void onMessage(final String payload) throws Exception {
        final JsonNode message = JSON_MAPPER.readTree(payload);

        // Special case for the initial handshake message
        if (message.has("uid")) {
            uidFuture.complete(message.get("uid").asText());
            return;
        }

        final String topic = message.get("topic").asText();
        final String name = topic.split("/")[0];

        if ("cosignature".equals(name)) {
            final String signer = message.get("data")
                .get("signerPublicKey").asText();
            System.out.println("cosignature: signer="
                + signer.substring(0, 16) + "...");
        } else if ("status".equals(name)) {
            handleStatus(message);
        } else if ("partialAdded".equals(name)) {
            handlePartialAdded(message);
        } else if ("confirmedAdded".equals(name)) {
            handleConfirmedAdded(message);
        } else {
            final String messageHash = message.get("data")
                .get("meta").get("hash").asText();
            System.out.printf("%s: hash=%s...%n",
                name, messageHash.substring(0, 16));
        }
    }

    private void handleStatus(final JsonNode message) {
        final String statusHash = message.get("data").get("hash").asText();
        System.out.println("status: hash="
            + statusHash.substring(0, 16) + "...");
        if (statusHash.equals(hashLockHash))
            hashLockConfirmed.completeExceptionally(new IOException(
                "Hash lock failed: "
                + message.get("data").get("code").asText()));
        if (statusHash.equals(bondedHash))
            confirmed.completeExceptionally(new IOException(
                "Transaction failed: "
                + message.get("data").get("code").asText()));
    }

    private void handlePartialAdded(final JsonNode message)
        throws IOException, InterruptedException {
        final String messageHash = message.get("data")
            .get("meta").get("hash").asText();
        System.out.println("partialAdded: hash="
            + messageHash.substring(0, 16) + "...");
        if (messageHash.equals(bondedHash)) {
            final DetachedCosignature cosignature =
                SymbolFacade.cosignTransactionHashDetached(
                    accountBKeyPair,
                    new CryptoTypes.Hash256(bondedHash));
            final String cosignaturePayload = JSON_MAPPER
                .writeValueAsString(cosignature.toJson());
            announceTransaction(
                cosignaturePayload, "/transactions/cosignature",
                "[Account B] Submitted cosignature");
        }
    }

    private void handleConfirmedAdded(final JsonNode message) {
        final String messageHash = message.get("data")
            .get("meta").get("hash").asText();
        System.out.println("confirmedAdded: hash="
            + messageHash.substring(0, 16) + "...");
        if (messageHash.equals(hashLockHash)) {
            System.out.println("Hash lock confirmed");
            hashLockConfirmed.complete(null);
        }
        if (messageHash.equals(bondedHash)) {
            System.out.println("Transaction "
                + bondedHash.substring(0, 16) + "... confirmed");
            confirmed.complete(null);
        }
    }

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 , 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();
        // Unsubscribe before closing
        for (final String channel : channels)
            remote.sendText(JSON_MAPPER.createObjectNode()
                .put("uid", uid).put("unsubscribe", channel)
                .toString());
        System.out.println("[Account B] Unsubscribed from all channels");
        session.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