Skip to content

Creating a Bonded Aggregate Transaction⚓︎

This tutorial shows how to create an asset swap using bonded aggregate transactions.

In this example, Account A sends 10 XYM to Account B, while Account B simultaneously sends 1 custom mosaic back to Account A:

%3clusterAggregateBonded Aggregate TransactionclusterT1Embedded Transfer 1clusterT2Embedded Transfer 2A2Account AB2Account BA2->B210 XYMA1Account AB1Account BA1->B11 Custom Mosaic

Two types of aggregate transactions

Aggregate transactions group multiple transactions in a single operation, and require signatures from all involved accounts.

A bonded aggregate transaction collects signatures on-chain after being announced. This works well when off-chain coordination is impractical. For example:

  • No shared infrastructure: Parties cannot coordinate through a common system, so the blockchain serves as the common interface.
  • Asynchronous workflows: Cosigners are not available at the same time or cannot coordinate in real-time.

To prevent spam, bonded aggregates require a hash lock (a deposit of 10 XYM). The network returns this deposit when all cosignatures arrive and the transaction reaches confirmation.

If parties can communicate off-chain to exchange signatures, complete aggregate transactions don't require this deposit.

Prerequisites⚓︎

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

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:

  • Create an account (Account A) to initiate the aggregate transaction, either from code or by using a wallet.
  • Create a second account (Account B) to participate in the swap.
  • Obtain XYM for Account A to pay for the transaction fee, transfer amounts, and the hash lock deposit. See Getting Testnet Funds from the Faucet.
  • Create a mosaic owned by Account B for the swap.

Additionally, review the Transfer transaction tutorial to understand how transactions are announced and confirmed.

Full Code⚓︎

import json
import os
import time
import urllib.request

from symbolchain.CryptoTypes import Hash256, 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

NODE_URL = os.environ.get(
    'NODE_URL', 'https://001-sai-dual.symboltest.net:3001')
print(f'Using node {NODE_URL}')

# Helper function to announce transaction
def announce_transaction(payload, endpoint, label):
    print(f'Announcing {label} to {endpoint}')
    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:
        print(f'  Response: {response.read().decode()}')

# Helper function to wait for transaction status
def wait_for_status(hash_value, expected_status, label):
    print(f'Waiting for {label} to reach {expected_status} status...')
    attempts = 0
    max_attempts = 60

    while attempts < max_attempts:
        try:
            url = f'{NODE_URL}/transactionStatus/{hash_value}'
            with urllib.request.urlopen(url) as response:
                status = json.loads(response.read().decode())

                print(f'  Transaction status: {status["group"]}')

                if status['group'] == 'failed':
                    raise Exception(f'{label} failed: {status["code"]}')

                if status['group'] == expected_status:
                    print(f'{label} {expected_status} ' +
                    f'in {attempts} seconds')
                    return

        except urllib.error.HTTPError as e:
            if e.code != 404:
                raise
            # Transaction status not yet available

        attempts += 1
        time.sleep(1)

    raise Exception(
        f'{label} not {expected_status} after {max_attempts} attempts'
    )

# Account A (initiates the aggregate tx and sends XYM to Account B)
ACCOUNT_A_PRIVATE_KEY = os.getenv(
    'ACCOUNT_A_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000000')
account_a_key_pair = SymbolFacade.KeyPair(
    PrivateKey(ACCOUNT_A_PRIVATE_KEY))

# Account B (sends custom mosaic to Account A)
ACCOUNT_B_PRIVATE_KEY = os.getenv(
    'ACCOUNT_B_PRIVATE_KEY',
    '1111111111111111111111111111111111111111111111111111111111111111')
account_b_key_pair = SymbolFacade.KeyPair(
    PrivateKey(ACCOUNT_B_PRIVATE_KEY))

facade = SymbolFacade('testnet')
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}')

try:
    # Fetch current network time
    time_path = '/node/time'
    print(f'Fetching current network time from {time_path}')
    with urllib.request.urlopen(f'{NODE_URL}{time_path}') as response:
        response_json = json.loads(response.read().decode())
        receive_timestamp = (
            response_json['communicationTimestamps']['receiveTimestamp'])
        timestamp = NetworkTimestamp(int(receive_timestamp))
        print(f'  Network time: {timestamp.timestamp} ms since nemesis')

    # Fetch recommended fees
    fee_path = '/network/fees/transaction'
    print(f'Fetching recommended fees from {fee_path}')
    with urllib.request.urlopen(f'{NODE_URL}{fee_path}') as response:
        response_json = json.loads(response.read().decode())
        median_mult = response_json['medianFeeMultiplier']
        minimum_mult = response_json['minFeeMultiplier']
        fee_mult = max(median_mult, minimum_mult)
        print(f'  Fee multiplier: {fee_mult}')

    # Embedded tx 1: Account A transfers 10 XYM to Account B
    embedded_transaction_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  # 10 XYM (divisibility = 6)
        }]
    })

    # Embedded tx 2: Account B transfers 1 custom mosaic to Account A
    custom_mosaic_id = 0x6D1314BE751B62C2
    embedded_transaction_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  # 1 custom mosaic (divisibility = 0)
        }]
    })

    # Build the bonded aggregate transaction
    embedded_transactions = [
        embedded_transaction_1, embedded_transaction_2]
    bonded_transaction = 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_transactions),
        'transactions': embedded_transactions
    })
    # Reserve space for one cosignature (104 bytes)
    # and calculate fee for the final transaction size
    bonded_transaction.fee = Amount(
        fee_mult * (bonded_transaction.size + 104))
    print('Built aggregate without signatures:')
    print(json.dumps(bonded_transaction.to_json(), indent=2))

    # --- ACCOUNT A (Initiator) ---
    # Sign the bonded aggregate transaction
    print('[Account A] Signing the bonded aggregate...')
    bonded_signature = facade.sign_transaction(
        account_a_key_pair, bonded_transaction)
    bonded_json_payload = facade.transaction_factory.attach_signature(
        bonded_transaction, bonded_signature)
    bonded_hash = facade.hash_transaction(bonded_transaction)
    print(f'Bonded aggregate transaction hash: {bonded_hash}')

    # Create hash lock transaction
    print('Creating 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  # 10 XYM deposit
        },
        'duration': 100,  # Lock duration in blocks
        'hash': bonded_hash
    })
    hash_lock.fee = Amount(fee_mult * hash_lock.size)

    # Sign hash lock
    print('[Account A] Signing the hash lock...')
    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)
    print(f'Hash lock transaction hash: {hash_lock_hash}')

    # Announce hash lock and wait for confirmation
    announce_transaction(hash_lock_payload, '/transactions', 'Hash lock')
    wait_for_status(hash_lock_hash, 'confirmed', 'Hash lock')

    # Announce bonded aggregate and wait for partial status
    announce_transaction(
        bonded_json_payload, '/transactions/partial',
        'Bonded aggregate transaction'
    )
    wait_for_status(
        bonded_hash, 'partial',
        'Bonded aggregate transaction'
    )

    # --- ACCOUNT B (Cosigner) ---
    # Retrieves partial transactions waiting for signature
    partial_path = f'/transactions/partial?address={account_b_address}'
    print(
        '[Account B] Checking for partial transactions from '
        '/transactions/partial'
    )
    with urllib.request.urlopen(f'{NODE_URL}{partial_path}') as response:
        partial_txs = json.loads(response.read().decode())
        if not partial_txs['data']:
            raise Exception('No partial transactions found')

    print(f'Found {len(partial_txs["data"])} partial transaction(s)')

    # Find the transaction matching the expected hash
    found = any(
        tx['meta']['hash'] == str(bonded_hash)
        for tx in partial_txs['data']
    )
    if not found:
        raise Exception(
            f'Expected transaction {bonded_hash} not found in '
            f'partial transactions')
    print(f'Found matching transaction: {bonded_hash}')

    # Fetch full transaction details using the hash
    detail_path = f'/transactions/partial/{bonded_hash}'
    with urllib.request.urlopen(f'{NODE_URL}{detail_path}') as response:
        partial_tx_json = json.loads(response.read().decode())

    # Verify transaction content before cosigning
    tx_data = partial_tx_json['transaction']
    print(
        f'[Account B] Verifying transaction: '
        f'{len(tx_data["transactions"])} embedded transactions'
    )

    # Submit Account B's cosignature using the transaction hash
    cosignature_path = '/transactions/cosignature'
    print('[Account B] Cosigning the bonded aggregate...')
    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 cosignature
    announce_transaction(
        cosignature_payload, cosignature_path, 'cosignature'
    )

    # Wait for final confirmation
    wait_for_status(
        bonded_hash, 'confirmed',
        'Bonded aggregate transaction'
    )

except Exception as e:
    print(e)

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://001-sai-dual.symboltest.net:3001';
console.log('Using node', NODE_URL);

// Helper function to announce transaction
async function announceTransaction(payload, endpoint, label) {
    console.log(`Announcing ${label} to ${endpoint}`);
    const response = await fetch(`${NODE_URL}${endpoint}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: payload
    });
    console.log('  Response:', await response.text());
}

// Helper function to wait for transaction status
async function waitForStatus(hash, expectedStatus, label) {
    console.log(
        `Waiting for ${label} to reach ${expectedStatus} status...`);
    let attempts = 0;
    const maxAttempts = 60;

    while (attempts < maxAttempts) {
        try {
            const url = `${NODE_URL}/transactionStatus/${hash}`;
            const response = await fetch(url);

            if (!response.ok) {
                const error = new Error(
                    `HTTP ${response.status}: ${response.statusText}`);
                error.status = response.status;
                throw error;
            }

            const status = await response.json();

            console.log('  Transaction status:', status.group);

            if (status.group === 'failed') {
                throw new Error(`${label} failed: ${status.code}`);
            }

            if (status.group === expectedStatus) {
                console.log(
                    `${label} ${expectedStatus} ` +
                    `in ${attempts} seconds`
                );
                return;
            }

        } catch (error) {
            if (error.status === 404) {
                // Transaction status not yet available
            } else {
                throw error;
            }
        }

        attempts++;
        await new Promise(resolve => setTimeout(resolve, 1000));
    }

    throw new Error(
        `${label} not ${expectedStatus} after ${maxAttempts} attempts`
    );
}

// Account A (initiates the aggregate tx and sends XYM to Account B)
const ACCOUNT_A_PRIVATE_KEY = process.env.ACCOUNT_A_PRIVATE_KEY || (
    '0000000000000000000000000000000000000000000000000000000000000000');
const accountAKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(ACCOUNT_A_PRIVATE_KEY));

// Account B (sends custom mosaic to Account A)
const ACCOUNT_B_PRIVATE_KEY = process.env.ACCOUNT_B_PRIVATE_KEY || (
    '1111111111111111111111111111111111111111111111111111111111111111');
const accountBKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(ACCOUNT_B_PRIVATE_KEY));

const facade = new SymbolFacade('testnet');
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 timePath = '/node/time';
    console.log('Fetching current network time from', timePath);
    const timeResponse = await fetch(`${NODE_URL}${timePath}`);
    const timeJSON = await timeResponse.json();
    const timestamp = new NetworkTimestamp(
        timeJSON.communicationTimestamps.receiveTimestamp);
    console.log('  Network time:', timestamp.timestamp,
        'ms since nemesis');

    // Fetch recommended fees
    const feePath = '/network/fees/transaction';
    console.log('Fetching recommended fees from', feePath);
    const feeResponse = await fetch(`${NODE_URL}${feePath}`);
    const feeJSON = await feeResponse.json();
    const medianMult = feeJSON.medianFeeMultiplier;
    const minimumMult = feeJSON.minFeeMultiplier;
    const feeMult = Math.max(medianMult, minimumMult);
    console.log('  Fee multiplier:', feeMult);

    // Embedded tx 1: Account A transfers 10 XYM to Account B
    const embeddedTransaction1 = facade.transactionFactory
        .createEmbedded({
            type: 'transfer_transaction_v1',
            signerPublicKey: accountAKeyPair.publicKey.toString(),
            recipientAddress: accountBAddress.toString(),
            mosaics: [{
                mosaicId: generateMosaicAliasId('symbol.xym'),
                amount: 10_000_000n  // 10 XYM (divisibility = 6)
            }]
        });

    // Embedded tx 2: Account B transfers 1 custom mosaic to Account A
    const customMosaicId = 0x6D1314BE751B62C2n;
    const embeddedTransaction2 = facade.transactionFactory
        .createEmbedded({
            type: 'transfer_transaction_v1',
            signerPublicKey: accountBKeyPair.publicKey.toString(),
            recipientAddress: accountAAddress.toString(),
            mosaics: [{
                mosaicId: customMosaicId,
                amount: 1n  // 1 custom mosaic (divisibility = 0)
            }]
        });

    // Build the bonded aggregate transaction
    const embeddedTransactions = [
        embeddedTransaction1, embeddedTransaction2];
    const bondedTransaction = facade.transactionFactory.create({
        type: 'aggregate_bonded_transaction_v3',
        signerPublicKey: accountAKeyPair.publicKey.toString(),
        deadline: timestamp.addHours(2).timestamp,
        transactionsHash: facade.static.hashEmbeddedTransactions(
            embeddedTransactions),
        transactions: embeddedTransactions
    });
    // Reserve space for one cosignature (104 bytes)
    // and calculate fee for the final transaction size
    bondedTransaction.fee = new models.Amount(
        feeMult * (bondedTransaction.size + 104)
    );
    console.log('Built aggregate without signatures:');
    console.log(JSON.stringify(bondedTransaction.toJson(), null, 2));

    // --- ACCOUNT A (Initiator) ---
    // Sign the bonded aggregate transaction
    console.log('[Account A] Signing the bonded aggregate...');
    const bondedSignature = facade.signTransaction(
        accountAKeyPair, bondedTransaction);
    const bondedJsonPayload = facade.transactionFactory.static
        .attachSignature(bondedTransaction, bondedSignature);
    const bondedHash = facade.hashTransaction(
        bondedTransaction).toString();
    console.log('Bonded aggregate transaction hash:', bondedHash);

    // Create hash lock transaction
    console.log('Creating 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  // 10 XYM deposit
        },
        duration: 100n,  // Lock duration in blocks
        hash: bondedHash
    });
    hashLock.fee = new models.Amount(feeMult * hashLock.size);

    // Sign hash lock
    console.log('[Account A] Signing the hash lock...');
    const hashLockSignature = facade.signTransaction(
        accountAKeyPair, hashLock);
    const hashLockPayload = facade.transactionFactory.static
        .attachSignature(hashLock, hashLockSignature);
    const hashLockHash = facade.hashTransaction(hashLock)
        .toString();
    console.log('Hash lock transaction hash:', hashLockHash);

    // Announce hash lock and wait for confirmation
    await announceTransaction(
        hashLockPayload, '/transactions', 'Hash lock'
    );
    await waitForStatus(hashLockHash, 'confirmed', 'Hash lock');

    // Announce bonded aggregate and wait for partial status
    await announceTransaction(
        bondedJsonPayload, '/transactions/partial',
        'Bonded aggregate transaction'
    );
    await waitForStatus(
        bondedHash, 'partial', 'Bonded aggregate transaction'
    );

    // --- ACCOUNT B (Cosigner) ---
    // Retrieves partial transactions waiting for signature
    const partialPath =
        `/transactions/partial?address=${accountBAddress}`;
    console.log(
        '[Account B] Checking for partial transactions from ' +
        '/transactions/partial'
    );
    const partialResponse = await fetch(`${NODE_URL}${partialPath}`);
    const partialTxs = await partialResponse.json();
    if (!partialTxs.data || partialTxs.data.length === 0) {
        throw new Error('No partial transactions found');
    }

    console.log(`Found ${partialTxs.data.length} partial transaction(s)`);

    // Find the transaction matching the expected hash
    const found = partialTxs.data.some(
        tx => tx.meta.hash === bondedHash
    );
    if (!found) {
        throw new Error(
            `Expected transaction ${bondedHash} not found in ` +
            `partial transactions`
        );
    }
    console.log(`Found matching transaction: ${bondedHash}`);

    // Fetch full transaction details using the hash
    const detailPath = `/transactions/partial/${bondedHash}`;
    const detailResponse = await fetch(`${NODE_URL}${detailPath}`);
    const partialTxJson = await detailResponse.json();

    // Verify transaction content before cosigning
    const txData = partialTxJson.transaction;
    console.log(
        `[Account B] Verifying transaction: ` +
        `${txData.transactions.length} embedded transactions`
    );

    // Submit Account B's cosignature using the transaction hash
    const cosignaturePath = '/transactions/cosignature';
    console.log('[Account B] Cosigning the bonded aggregate...');
    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()
    });

    // Announce cosignature
    await announceTransaction(
        cosignaturePayload, cosignaturePath, 'cosignature'
    );

    // Wait for final confirmation
    await waitForStatus(
        new Hash256(bondedHash), 'confirmed',
        'Bonded aggregate transaction'
    );
} catch (e) {
    console.error(e.message, '| Cause:', e.cause?.code ?? 'unknown');
}

Download source

The whole code is wrapped in a single try block to provide simple error handling, but applications will probably want to use more fine-grained control.

Account A: Initiator Workflow⚓︎

Setting Up Accounts⚓︎

# Account A (initiates the aggregate tx and sends XYM to Account B)
ACCOUNT_A_PRIVATE_KEY = os.getenv(
    'ACCOUNT_A_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000000')
account_a_key_pair = SymbolFacade.KeyPair(
    PrivateKey(ACCOUNT_A_PRIVATE_KEY))

# Account B (sends custom mosaic to Account A)
ACCOUNT_B_PRIVATE_KEY = os.getenv(
    'ACCOUNT_B_PRIVATE_KEY',
    '1111111111111111111111111111111111111111111111111111111111111111')
account_b_key_pair = SymbolFacade.KeyPair(
    PrivateKey(ACCOUNT_B_PRIVATE_KEY))

facade = SymbolFacade('testnet')
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}')
// Account A (initiates the aggregate tx and sends XYM to Account B)
const ACCOUNT_A_PRIVATE_KEY = process.env.ACCOUNT_A_PRIVATE_KEY || (
    '0000000000000000000000000000000000000000000000000000000000000000');
const accountAKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(ACCOUNT_A_PRIVATE_KEY));

// Account B (sends custom mosaic to Account A)
const ACCOUNT_B_PRIVATE_KEY = process.env.ACCOUNT_B_PRIVATE_KEY || (
    '1111111111111111111111111111111111111111111111111111111111111111');
const accountBKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(ACCOUNT_B_PRIVATE_KEY));

const facade = new SymbolFacade('testnet');
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 to demonstrate the complete workflow, but in practice each party would sign on their own machine without sharing private keys.

The snippet reads the private keys from the ACCOUNT_A_PRIVATE_KEY and ACCOUNT_B_PRIVATE_KEY environment variables, which default to test keys if not set. If using your own keys, ensure Account A has XYM and Account B holds a custom mosaic for the swap.

The addresses for both accounts are derived from their public keys using the facade's network configuration.

Fetching Network Time and Fees⚓︎

    # Fetch current network time
    time_path = '/node/time'
    print(f'Fetching current network time from {time_path}')
    with urllib.request.urlopen(f'{NODE_URL}{time_path}') as response:
        response_json = json.loads(response.read().decode())
        receive_timestamp = (
            response_json['communicationTimestamps']['receiveTimestamp'])
        timestamp = NetworkTimestamp(int(receive_timestamp))
        print(f'  Network time: {timestamp.timestamp} ms since nemesis')

    # Fetch recommended fees
    fee_path = '/network/fees/transaction'
    print(f'Fetching recommended fees from {fee_path}')
    with urllib.request.urlopen(f'{NODE_URL}{fee_path}') as response:
        response_json = json.loads(response.read().decode())
        median_mult = response_json['medianFeeMultiplier']
        minimum_mult = response_json['minFeeMultiplier']
        fee_mult = max(median_mult, minimum_mult)
        print(f'  Fee multiplier: {fee_mult}')
    // Fetch current network time
    const timePath = '/node/time';
    console.log('Fetching current network time from', timePath);
    const timeResponse = await fetch(`${NODE_URL}${timePath}`);
    const timeJSON = await timeResponse.json();
    const timestamp = new NetworkTimestamp(
        timeJSON.communicationTimestamps.receiveTimestamp);
    console.log('  Network time:', timestamp.timestamp,
        'ms since nemesis');

    // Fetch recommended fees
    const feePath = '/network/fees/transaction';
    console.log('Fetching recommended fees from', feePath);
    const feeResponse = await fetch(`${NODE_URL}${feePath}`);
    const feeJSON = await feeResponse.json();
    const medianMult = feeJSON.medianFeeMultiplier;
    const minimumMult = feeJSON.minFeeMultiplier;
    const feeMult = Math.max(medianMult, minimumMult);
    console.log('  Fee multiplier:', feeMult);

To prepare an aggregate, first retrieve the current network time from /node/time GET and the recommended fee multiplier from /network/fees/transaction GET, following the same steps described in the Transfer Transaction tutorial.

Creating Embedded Transactions⚓︎

    # Embedded tx 1: Account A transfers 10 XYM to Account B
    embedded_transaction_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  # 10 XYM (divisibility = 6)
        }]
    })

    # Embedded tx 2: Account B transfers 1 custom mosaic to Account A
    custom_mosaic_id = 0x6D1314BE751B62C2
    embedded_transaction_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  # 1 custom mosaic (divisibility = 0)
        }]
    })
    // Embedded tx 1: Account A transfers 10 XYM to Account B
    const embeddedTransaction1 = facade.transactionFactory
        .createEmbedded({
            type: 'transfer_transaction_v1',
            signerPublicKey: accountAKeyPair.publicKey.toString(),
            recipientAddress: accountBAddress.toString(),
            mosaics: [{
                mosaicId: generateMosaicAliasId('symbol.xym'),
                amount: 10_000_000n  // 10 XYM (divisibility = 6)
            }]
        });

    // Embedded tx 2: Account B transfers 1 custom mosaic to Account A
    const customMosaicId = 0x6D1314BE751B62C2n;
    const embeddedTransaction2 = facade.transactionFactory
        .createEmbedded({
            type: 'transfer_transaction_v1',
            signerPublicKey: accountBKeyPair.publicKey.toString(),
            recipientAddress: accountAAddress.toString(),
            mosaics: [{
                mosaicId: customMosaicId,
                amount: 1n  // 1 custom mosaic (divisibility = 0)
            }]
        });

The embedded transactions define the operations to execute atomically. Each embedded transaction specifies:

  • Type: All transaction types can be embedded within aggregates (except other aggregates). For embedded transfers, use transfer_transaction_v1, the same as for basic transfer transactions.

  • Signer public key: The account that would sign this transaction if it were announced independently.

  • Transaction-specific fields: All fields specific to the transaction type must be provided. For transfers, this includes the recipient address and the mosaics to send.

Note that embedded transactions do not include fee or deadline fields. These are inherited from the enclosing aggregate transaction.

The example creates two transfer transactions for the swap:

  • The first transfer sends 10 XYM from Account A to Account B.
  • The second transfer sends 1 custom mosaic from Account B to Account A.

About the custom mosaic

The custom mosaic with ID 0x6D1314BE751B62C2 was created for this tutorial. The default Account B has been seeded with this mosaic so the swap can execute successfully.

If using your own accounts, ensure Account B holds a custom mosaic and update the mosaic ID in the code.

Building the Aggregate Transaction⚓︎

    # Build the bonded aggregate transaction
    embedded_transactions = [
        embedded_transaction_1, embedded_transaction_2]
    bonded_transaction = 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_transactions),
        'transactions': embedded_transactions
    })
    # Reserve space for one cosignature (104 bytes)
    # and calculate fee for the final transaction size
    bonded_transaction.fee = Amount(
        fee_mult * (bonded_transaction.size + 104))
    print('Built aggregate without signatures:')
    print(json.dumps(bonded_transaction.to_json(), indent=2))
    // Build the bonded aggregate transaction
    const embeddedTransactions = [
        embeddedTransaction1, embeddedTransaction2];
    const bondedTransaction = facade.transactionFactory.create({
        type: 'aggregate_bonded_transaction_v3',
        signerPublicKey: accountAKeyPair.publicKey.toString(),
        deadline: timestamp.addHours(2).timestamp,
        transactionsHash: facade.static.hashEmbeddedTransactions(
            embeddedTransactions),
        transactions: embeddedTransactions
    });
    // Reserve space for one cosignature (104 bytes)
    // and calculate fee for the final transaction size
    bondedTransaction.fee = new models.Amount(
        feeMult * (bondedTransaction.size + 104)
    );
    console.log('Built aggregate without signatures:');
    console.log(JSON.stringify(bondedTransaction.toJson(), null, 2));

Once the embedded transactions are prepared, create the bonded aggregate transaction that wraps them:

  • Type: Use aggregate_bonded_transaction_v3.

  • Signer public key: The account initiating the aggregate. This account announces the transaction and pays the transaction fee.

    Sharing transaction fees

    While the signer pays the entire fee upfront, other participants can contribute to the cost by including XYM transfers back to the signer within the aggregate.

    For example, Account B could add XYM to its existing transfer to Account A, or include a separate embedded transfer transaction for the fee contribution.

    This technique allows parties to split costs or even enables one account to send transactions without holding XYM, since another account covers the fee.

  • Deadline: The timestamp, in network time, after which the transaction expires and can no longer be confirmed.

  • Transactions hash: A hash computed from all embedded transactions. This ensures the embedded transactions cannot be modified after signing. Use to compute this value.

  • Transactions: The array of embedded transactions to execute.

The fee is calculated based on the aggregate's total size, which includes all embedded transactions plus space reserved for one cosignature (104 bytes).

Signing the Bonded Transaction⚓︎

    # --- ACCOUNT A (Initiator) ---
    # Sign the bonded aggregate transaction
    print('[Account A] Signing the bonded aggregate...')
    bonded_signature = facade.sign_transaction(
        account_a_key_pair, bonded_transaction)
    bonded_json_payload = facade.transaction_factory.attach_signature(
        bonded_transaction, bonded_signature)
    bonded_hash = facade.hash_transaction(bonded_transaction)
    print(f'Bonded aggregate transaction hash: {bonded_hash}')
    // --- ACCOUNT A (Initiator) ---
    // Sign the bonded aggregate transaction
    console.log('[Account A] Signing the bonded aggregate...');
    const bondedSignature = facade.signTransaction(
        accountAKeyPair, bondedTransaction);
    const bondedJsonPayload = facade.transactionFactory.static
        .attachSignature(bondedTransaction, bondedSignature);
    const bondedHash = facade.hashTransaction(
        bondedTransaction).toString();
    console.log('Bonded aggregate transaction hash:', bondedHash);

Account A signs the bonded transaction, producing the main signature and finalizing the transaction hash.

This hash is required for the next step: creating a hash lock transaction.

Creating the Hash Lock⚓︎

    # Create hash lock transaction
    print('Creating 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  # 10 XYM deposit
        },
        'duration': 100,  # Lock duration in blocks
        'hash': bonded_hash
    })
    hash_lock.fee = Amount(fee_mult * hash_lock.size)

    # Sign hash lock
    print('[Account A] Signing the hash lock...')
    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)
    print(f'Hash lock transaction hash: {hash_lock_hash}')

    # Announce hash lock and wait for confirmation
    announce_transaction(hash_lock_payload, '/transactions', 'Hash lock')
    wait_for_status(hash_lock_hash, 'confirmed', 'Hash lock')
    // Create hash lock transaction
    console.log('Creating 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  // 10 XYM deposit
        },
        duration: 100n,  // Lock duration in blocks
        hash: bondedHash
    });
    hashLock.fee = new models.Amount(feeMult * hashLock.size);

    // Sign hash lock
    console.log('[Account A] Signing the hash lock...');
    const hashLockSignature = facade.signTransaction(
        accountAKeyPair, hashLock);
    const hashLockPayload = facade.transactionFactory.static
        .attachSignature(hashLock, hashLockSignature);
    const hashLockHash = facade.hashTransaction(hashLock)
        .toString();
    console.log('Hash lock transaction hash:', hashLockHash);

    // Announce hash lock and wait for confirmation
    await announceTransaction(
        hashLockPayload, '/transactions', 'Hash lock'
    );
    await waitForStatus(hashLockHash, 'confirmed', 'Hash lock');

Before announcing a bonded aggregate, a hash lock transaction must be created and confirmed. The hash lock serves as a deposit to prevent spam and ensure network resources are not exhausted by unfinished partial transactions.

The hash lock transaction specifies:

  • Type: Use hash_lock_transaction_v1.

  • Mosaic: The deposit amount (10 XYM). This deposit is locked temporarily while waiting for cosignatures.

  • Duration: The number of blocks the deposit remains locked (100 blocks in this example). If all cosignatures are collected and the bonded aggregate confirms before the duration expires, the deposit is returned. Otherwise, it is forfeited.

  • Hash: The hash of the bonded aggregate transaction being locked.

The hash lock is signed using and announced using the announce_transaction helper function. It must be confirmed before the bonded aggregate can be announced.

Then, the wait_for_status helper function polls the transaction status until confirmation.

Announcing the Bonded Transaction⚓︎

    # Announce bonded aggregate and wait for partial status
    announce_transaction(
        bonded_json_payload, '/transactions/partial',
        'Bonded aggregate transaction'
    )
    wait_for_status(
        bonded_hash, 'partial',
        'Bonded aggregate transaction'
    )
    // Announce bonded aggregate and wait for partial status
    await announceTransaction(
        bondedJsonPayload, '/transactions/partial',
        'Bonded aggregate transaction'
    );
    await waitForStatus(
        bondedHash, 'partial', 'Bonded aggregate transaction'
    );

Once the hash lock is confirmed, the bonded aggregate is announced to /transactions/partial PUT using the announce_transaction helper.

The node validates the transaction, checks that a valid hash lock exists, and places it in a partial state. The wait_for_status helper monitors the transaction until it reaches this state, at which point it can collect cosignatures.

Account B: Cosigner Workflow⚓︎

Recovering the Transaction⚓︎

    # --- ACCOUNT B (Cosigner) ---
    # Retrieves partial transactions waiting for signature
    partial_path = f'/transactions/partial?address={account_b_address}'
    print(
        '[Account B] Checking for partial transactions from '
        '/transactions/partial'
    )
    with urllib.request.urlopen(f'{NODE_URL}{partial_path}') as response:
        partial_txs = json.loads(response.read().decode())
        if not partial_txs['data']:
            raise Exception('No partial transactions found')

    print(f'Found {len(partial_txs["data"])} partial transaction(s)')

    # Find the transaction matching the expected hash
    found = any(
        tx['meta']['hash'] == str(bonded_hash)
        for tx in partial_txs['data']
    )
    if not found:
        raise Exception(
            f'Expected transaction {bonded_hash} not found in '
            f'partial transactions')
    print(f'Found matching transaction: {bonded_hash}')
    // --- ACCOUNT B (Cosigner) ---
    // Retrieves partial transactions waiting for signature
    const partialPath =
        `/transactions/partial?address=${accountBAddress}`;
    console.log(
        '[Account B] Checking for partial transactions from ' +
        '/transactions/partial'
    );
    const partialResponse = await fetch(`${NODE_URL}${partialPath}`);
    const partialTxs = await partialResponse.json();
    if (!partialTxs.data || partialTxs.data.length === 0) {
        throw new Error('No partial transactions found');
    }

    console.log(`Found ${partialTxs.data.length} partial transaction(s)`);

    // Find the transaction matching the expected hash
    const found = partialTxs.data.some(
        tx => tx.meta.hash === bondedHash
    );
    if (!found) {
        throw new Error(
            `Expected transaction ${bondedHash} not found in ` +
            `partial transactions`
        );
    }
    console.log(`Found matching transaction: ${bondedHash}`);

Unlike complete aggregates where the transaction payload is shared off-chain, bonded aggregates enable on-chain coordination.

First, Account B polls /transactions/partial GET with the address parameter to find transactions waiting for its signature. This returns a list of partial transactions involving Account B.

This example looks for a specific transaction hash because both accounts run in the same script. In practice, Account B would discover pending transactions by polling and decide which ones to cosign based on their content.

Verifying the Transaction⚓︎

    # Fetch full transaction details using the hash
    detail_path = f'/transactions/partial/{bonded_hash}'
    with urllib.request.urlopen(f'{NODE_URL}{detail_path}') as response:
        partial_tx_json = json.loads(response.read().decode())

    # Verify transaction content before cosigning
    tx_data = partial_tx_json['transaction']
    print(
        f'[Account B] Verifying transaction: '
        f'{len(tx_data["transactions"])} embedded transactions'
    )
    // Fetch full transaction details using the hash
    const detailPath = `/transactions/partial/${bondedHash}`;
    const detailResponse = await fetch(`${NODE_URL}${detailPath}`);
    const partialTxJson = await detailResponse.json();

    // Verify transaction content before cosigning
    const txData = partialTxJson.transaction;
    console.log(
        `[Account B] Verifying transaction: ` +
        `${txData.transactions.length} embedded transactions`
    );

Once a transaction is found, Account B uses its hash to fetch the full details (including embedded transactions) from /transactions/partial/{transactionId} GET.

Before cosigning, Account B should verify that the embedded transactions match the expected operations. This example simply logs the number of embedded transactions, but it could also check amounts, recipients, and mosaics to ensure the swap terms are correct.

Verify before cosigning

Always inspect transaction content before cosigning. Cosignatures are binding and cannot be undone.

Cosigning the Transaction⚓︎

    # Submit Account B's cosignature using the transaction hash
    cosignature_path = '/transactions/cosignature'
    print('[Account B] Cosigning the bonded aggregate...')
    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 cosignature
    announce_transaction(
        cosignature_payload, cosignature_path, 'cosignature'
    )
    // Submit Account B's cosignature using the transaction hash
    const cosignaturePath = '/transactions/cosignature';
    console.log('[Account B] Cosigning the bonded aggregate...');
    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()
    });

    // Announce cosignature
    await announceTransaction(
        cosignaturePayload, cosignaturePath, 'cosignature'
    );

Account B cosigns the transaction using with the transaction hash and the detached parameter set to true.

A detached cosignature is a standalone object that can be submitted independently to the network. This is required for bonded aggregates because the cosigner submits directly to the node.

The resulting detached cosignature payload includes:

  • Version: The cosignature format version.
  • Signer public key: Account B's public key, identifying who cosigned.
  • Signature: The cryptographic signature computed from the transaction hash and Account B's private key.
  • Parent hash: The hash of the bonded transaction being cosigned.

The cosignature payload is submitted using the announce_transaction helper function to /transactions/cosignature PUT. The network validates the cosignature and attaches it to the partial transaction.

Once enough cosignatures are collected to satisfy all embedded transactions, the network automatically processes the bonded aggregate and includes it in a block.

Waiting for Confirmation⚓︎

    # Wait for final confirmation
    wait_for_status(
        bonded_hash, 'confirmed',
        'Bonded aggregate transaction'
    )
    // Wait for final confirmation
    await waitForStatus(
        new Hash256(bondedHash), 'confirmed',
        'Bonded aggregate transaction'
    );

The wait_for_status helper function polls /transactionStatus/{hash} GET until the transaction is confirmed or fails.

If all required cosignatures are collected before the deadline, the transaction confirms, both transfers execute, and the hash lock deposit is returned to Account A.

If the deadline expires or any cosignature is invalid, the transaction fails and the deposit is forfeited.

Output⚓︎

The output shown below corresponds to a typical run of the program.

Using node https://001-sai-dual.symboltest.net:3001
Account A: TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I
Account B: TCWYXKVYBMO4NBCUF3AXKJMXCGVSYQOS7ZG2TLI
Fetching current network time from /node/time
  Network time: 96983954275 ms since nemesis
Fetching recommended fees from /network/fees/transaction
  Fee multiplier: 100
Built aggregate without signatures:
{
  "signature": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
  "signer_public_key": "3B6A27BCCEB6A42D62A3A8D02A6F0D73653215771DE243A63AC048A18B59DA29",
  "version": 3,
  "network": 152,
  "type": 16961,
  "fee": "46400",
  "deadline": "96991154275",
  "transactions_hash": "C2F8137E86FA6CD2B5A6E0CBBEA5485DCC427C5F1C9AC98F7A35E84CF0A28877",
  "transactions": [
    {
      "signer_public_key": "3B6A27BCCEB6A42D62A3A8D02A6F0D73653215771DE243A63AC048A18B59DA29",
      "version": 1,
      "network": 152,
      "type": 16724,
      "recipient_address": "98AD8BAAB80B1DC684542EC175259711AB2C41D2FE4DA9AD",
      "mosaics": [
        {
          "mosaic_id": "16666583871264174062",
          "amount": "10000000"
        }
      ],
      "message": ""
    },
    {
      "signer_public_key": "D04AB232742BB4AB3A1368BD4615E4E6D0224AB71A016BAF8520A332C9778737",
      "version": 1,
      "network": 152,
      "type": 16724,
      "recipient_address": "988E1191A25A88142C2FB3F69787576E3DC713EFC1CE4DE9",
      "mosaics": [
        {
          "mosaic_id": "7859648582932718274",
          "amount": "1"
        }
      ],
      "message": ""
    }
  ],
  "cosignatures": []
}
[Account A] Signing the bonded aggregate...
Bonded aggregate transaction hash: 59CD54BA6EFC220C9C0C8DA6C7615F2A7F23A302836A69ACE29D48569D8CBAF8
Creating hash lock transaction...
[Account A] Signing the hash lock...
Hash lock transaction hash: 8E7AB27A920195BB783F822C39E14A42E2BA965250114E619D011CFE437ACC68
Announcing Hash lock to /transactions
  Response: {"message":"packet 9 was pushed to the network via /transactions"}
Waiting for Hash lock to reach confirmed status...
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  Transaction status: confirmed
Hash lock confirmed in 24 seconds
Announcing Bonded aggregate transaction to /transactions/partial
  Response: {"message":"packet 256 was pushed to the network via /transactions/partial"}
Waiting for Bonded aggregate transaction to reach partial status...
  Transaction status: partial
Bonded aggregate transaction partial in 1 seconds
[Account B] Checking for partial transactions from /transactions/partial
Found 1 partial transaction(s)
Found matching transaction: 59CD54BA6EFC220C9C0C8DA6C7615F2A7F23A302836A69ACE29D48569D8CBAF8
[Account B] Verifying transaction: 2 embedded transactions
[Account B] Cosigning the bonded aggregate...
Announcing cosignature to /transactions/cosignature
  Response: {"message":"packet 257 was pushed to the network via /transactions/cosignature"}
Waiting for Bonded aggregate transaction to reach confirmed status...
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  Transaction status: confirmed
Bonded aggregate transaction confirmed in 16 seconds

Key points in the output:

  • Line 14 ("type": 16961): Indicates this is an aggregate_bonded_transaction_v3.
  • Line 18 ("transactions"): Contains the two embedded transfers that will execute atomically.
  • Line 48 ("cosignatures": []): Initially empty. Cosignatures are submitted on-chain after announcement.
  • Line 51 (Bonded aggregate transaction hash:): The hash of the bonded aggregate, required for creating the hash lock and announcing the transaction.
  • Line 54 (Announcing Hash lock to /transactions): A hash lock must be announced and confirmed before the bonded aggregate.
  • Line 63 (Announcing Bonded aggregate transaction to /transactions/partial): Bonded aggregates use a different endpoint than regular transactions.
  • Line 67 (Bonded aggregate transaction partial in 1 seconds): The bonded aggregate is now waiting for cosignatures to be submitted on-chain.
  • Line 71 ([Account B] Verifying transaction: 2 embedded transactions): Account B inspects the transaction content before cosigning to ensure they agree with all operations.
  • Line 73 (Announcing cosignature to /transactions/cosignature): The cosignature is submitted to the network.

The aggregate transaction is treated as a single atomic unit by the network. The swap executes completely: Account A receives the custom mosaic and Account B receives the XYM, or the entire transaction fails and no assets are transferred.

Conclusion⚓︎

This tutorial showed how to:

Step Related documentation
Create embedded transactions
Build the aggregate
Sign the bonded transaction
Create hash lock
/transactions PUT
Announce bonded transaction /transactions/partial PUT
Recover the transaction /transactions/partial GET
Verify the transaction /transactions/partial/{transactionId} GET
Cosign the transaction
/transactions/cosignature PUT
Wait for confirmation /transactionStatus/{hash} GET