Skip to content

Creating a Complete Aggregate Transaction⚓︎

This tutorial shows how to create an asset swap using complete 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:

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

Both parties coordinate off-chain to collect signatures before announcement, ensuring the swap executes as a single atomic transaction.

Two types of aggregate transactions

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

A complete aggregate transaction collects all signatures before being announced. This procedure works well, for example, in the following scenarios:

  • Multi-party coordination: When parties can communicate off-chain to exchange transaction payloads and cosignatures.
  • Single-account batching: When one account wants to execute multiple transactions atomically. No cosignatures are needed in this case.

If off-chain coordination is impractical, use bonded aggregate transactions instead, which allow cosignatures to be added on-chain.

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 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}')

# 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 aggregate transaction
    embedded_transactions = [
        embedded_transaction_1, embedded_transaction_2]
    transaction = facade.transaction_factory.create({
        'type': 'aggregate_complete_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
    transaction.fee = Amount(fee_mult * (transaction.size + 104))
    print('Built aggregate transaction without signatures:')
    print(json.dumps(transaction.to_json(), indent=2))

    # --- ACCOUNT A (Initiator) ---
    print('[Account A] Signing the aggregate...')
    signature_a = facade.sign_transaction(account_a_key_pair, transaction)
    transaction_payload = facade.transaction_factory.attach_signature(
        transaction, signature_a)
    payload_formatted = json.dumps(
        json.loads(transaction_payload), indent=2)
    print(f'[Account A] Payload ready to share:\n{payload_formatted}')

    # --- OFF-CHAIN COORDINATION ---
    # Account A sends the payload to Account B
    shared_payload = transaction_payload
    print('[Account A] ==> Payload sent to Account B (offchain)')

    # --- ACCOUNT B (Cosignatory) ---
    received_transaction = facade.transaction_factory.deserialize(
        bytes.fromhex(json.loads(shared_payload)['payload']))

    print('[Account B] Cosigning...')
    cosignature_b = facade.cosign_transaction(
        account_b_key_pair, received_transaction)
    cosignature_formatted = json.dumps(cosignature_b.to_json(), indent=2)
    print(f'[Account B] Cosignature created: {cosignature_formatted}')

    # --- OFF-CHAIN COORDINATION ---
    # Account B sends the cosignature back to Account A
    shared_cosignature = cosignature_b
    print('[Account B] <== Cosignature sent back to Account A (offchain)')

    # --- ACCOUNT A (Initiator) ---
    # Add cosignature to the transaction and rebuild payload
    transaction.cosignatures.append(shared_cosignature)
    transaction_payload = facade.transaction_factory.attach_signature(
        transaction, signature_a)
    json_payload = transaction_payload
    print('[Account A] Ready to announce')

    # Announce the transaction
    announce_path = '/transactions'
    print(f'Announcing transaction to {announce_path}')
    announce_request = urllib.request.Request(
        f'{NODE_URL}{announce_path}',
        data=json_payload.encode(),
        headers={ 'Content-Type': 'application/json' },
        method='PUT'
    )
    with urllib.request.urlopen(announce_request) as response:
        print(f'  Response: {response.read().decode()}')

    # Compute hash of final transaction (with cosignatures)
    transaction_hash = facade.hash_transaction(transaction)

    # Wait for confirmation
    status_path = f'/transactionStatus/{transaction_hash}'
    print(f'Waiting for confirmation from {status_path}')
    for attempt in range(60):
        time.sleep(1)
        try:
            with urllib.request.urlopen(
                f'{NODE_URL}{status_path}'
            ) as response:
                status = json.loads(response.read().decode())
                print(f'  Transaction status: {status['group']}')
                if status['group'] == 'confirmed':
                    print(f'Transaction confirmed in {attempt} seconds')
                    break
                if status['group'] == 'failed':
                    print(f'Transaction failed: {status['code']}')
                    break
        except urllib.error.HTTPError as e:
            print(f'  Transaction status: unknown | Cause: ({e.msg})')
    else:
        print('Confirmation took too long.')

except Exception as e:
    print(e)

Download source

import { 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);

// 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 aggregate transaction
    const embeddedTransactions = [
        embeddedTransaction1, embeddedTransaction2];
    const transaction = facade.transactionFactory.create({
        type: 'aggregate_complete_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
    transaction.fee = new models.Amount(
        feeMult * (transaction.size + 104)
    );
    console.log('Built aggregate transaction without signatures:');
    console.log(JSON.stringify(transaction.toJson(), null, 2));

    // --- ACCOUNT A (Initiator) ---
    console.log('[Account A] Signing the aggregate...');
    const signatureA = facade.signTransaction(
        accountAKeyPair, transaction);
    const transactionPayload = facade.transactionFactory.static
        .attachSignature(transaction, signatureA);
    const payloadFormatted = JSON.stringify(
        JSON.parse(transactionPayload), null, 2);
    console.log('[Account A] Payload ready to share:\n',
        payloadFormatted);

    // --- OFF-CHAIN COORDINATION ---
    // Account A sends the payload to Account B
    const sharedPayload = transactionPayload;
    console.log('[Account A] ==> Payload sent to Account B (offchain)');

    // --- ACCOUNT B (Cosignatory) ---
    const payloadHex = JSON.parse(sharedPayload).payload;
    const receivedTransaction = facade.transactionFactory.static
        .deserialize(Buffer.from(payloadHex, 'hex'));

    console.log('[Account B] Cosigning...');
    const cosignatureB = facade.cosignTransaction(
        accountBKeyPair, receivedTransaction);
    const cosignatureFormatted = JSON.stringify(
        cosignatureB.toJson(), null, 2);
    console.log('[Account B] Cosignature created:',
        cosignatureFormatted);

    // --- OFF-CHAIN COORDINATION ---
    // Account B sends the cosignature back to Account A
    const sharedCosignature = cosignatureB;
    console.log('[Account B] <== Cosignature sent back to Account A',
        '(offchain)');

    // --- ACCOUNT A (Initiator) ---
    // Add cosignature to the transaction and rebuild payload
    transaction.cosignatures.push(sharedCosignature);
    const transactionPayloadFinal = facade.transactionFactory.static
        .attachSignature(transaction, signatureA);
    const jsonPayload = transactionPayloadFinal;
    console.log('[Account A] Ready to announce');

    // Announce the transaction
    const announcePath = '/transactions';
    console.log('Announcing transaction to', announcePath);
    const announceResponse = await fetch(`${NODE_URL}${announcePath}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: jsonPayload
    });
    console.log('  Response:', await announceResponse.text());

    // Compute hash of final transaction (with cosignatures)
    const transactionHash =
        facade.hashTransaction(transaction).toString();

    // Wait for confirmation
    const statusPath = `/transactionStatus/${transactionHash}`;
    console.log('Waiting for confirmation from', statusPath);

    for (let attempt = 0; attempt < 60; attempt++) {
        await new Promise(resolve => setTimeout(resolve, 1000));
        try {
            const statusResponse = await fetch(
                `${NODE_URL}${statusPath}`);
            const status = await statusResponse.json();
            console.log('  Transaction status:', status.group);
            if (status.group === 'confirmed') {
                console.log('Transaction confirmed in', attempt,
                    'seconds');
                break;
            }
            if (status.group === 'failed') {
                console.log('Transaction failed:', status.code);
                break;
            }
        } catch (e) {
            console.log('  Transaction status: unknown | Cause:',
                e.message);
        }
    }
} 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.

Code Explanation⚓︎

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 aggregate transaction
    embedded_transactions = [
        embedded_transaction_1, embedded_transaction_2]
    transaction = facade.transaction_factory.create({
        'type': 'aggregate_complete_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
    transaction.fee = Amount(fee_mult * (transaction.size + 104))
    print('Built aggregate transaction without signatures:')
    print(json.dumps(transaction.to_json(), indent=2))
    // Build the aggregate transaction
    const embeddedTransactions = [
        embeddedTransaction1, embeddedTransaction2];
    const transaction = facade.transactionFactory.create({
        type: 'aggregate_complete_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
    transaction.fee = new models.Amount(
        feeMult * (transaction.size + 104)
    );
    console.log('Built aggregate transaction without signatures:');
    console.log(JSON.stringify(transaction.toJson(), null, 2));

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

  • Type: Use aggregate_complete_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).

Collecting Signatures⚓︎

    # --- ACCOUNT A (Initiator) ---
    print('[Account A] Signing the aggregate...')
    signature_a = facade.sign_transaction(account_a_key_pair, transaction)
    transaction_payload = facade.transaction_factory.attach_signature(
        transaction, signature_a)
    payload_formatted = json.dumps(
        json.loads(transaction_payload), indent=2)
    print(f'[Account A] Payload ready to share:\n{payload_formatted}')

    # --- OFF-CHAIN COORDINATION ---
    # Account A sends the payload to Account B
    shared_payload = transaction_payload
    print('[Account A] ==> Payload sent to Account B (offchain)')

    # --- ACCOUNT B (Cosignatory) ---
    received_transaction = facade.transaction_factory.deserialize(
        bytes.fromhex(json.loads(shared_payload)['payload']))

    print('[Account B] Cosigning...')
    cosignature_b = facade.cosign_transaction(
        account_b_key_pair, received_transaction)
    cosignature_formatted = json.dumps(cosignature_b.to_json(), indent=2)
    print(f'[Account B] Cosignature created: {cosignature_formatted}')

    # --- OFF-CHAIN COORDINATION ---
    # Account B sends the cosignature back to Account A
    shared_cosignature = cosignature_b
    print('[Account B] <== Cosignature sent back to Account A (offchain)')

    # --- ACCOUNT A (Initiator) ---
    # Add cosignature to the transaction and rebuild payload
    transaction.cosignatures.append(shared_cosignature)
    transaction_payload = facade.transaction_factory.attach_signature(
        transaction, signature_a)
    json_payload = transaction_payload
    print('[Account A] Ready to announce')
    // --- ACCOUNT A (Initiator) ---
    console.log('[Account A] Signing the aggregate...');
    const signatureA = facade.signTransaction(
        accountAKeyPair, transaction);
    const transactionPayload = facade.transactionFactory.static
        .attachSignature(transaction, signatureA);
    const payloadFormatted = JSON.stringify(
        JSON.parse(transactionPayload), null, 2);
    console.log('[Account A] Payload ready to share:\n',
        payloadFormatted);

    // --- OFF-CHAIN COORDINATION ---
    // Account A sends the payload to Account B
    const sharedPayload = transactionPayload;
    console.log('[Account A] ==> Payload sent to Account B (offchain)');

    // --- ACCOUNT B (Cosignatory) ---
    const payloadHex = JSON.parse(sharedPayload).payload;
    const receivedTransaction = facade.transactionFactory.static
        .deserialize(Buffer.from(payloadHex, 'hex'));

    console.log('[Account B] Cosigning...');
    const cosignatureB = facade.cosignTransaction(
        accountBKeyPair, receivedTransaction);
    const cosignatureFormatted = JSON.stringify(
        cosignatureB.toJson(), null, 2);
    console.log('[Account B] Cosignature created:',
        cosignatureFormatted);

    // --- OFF-CHAIN COORDINATION ---
    // Account B sends the cosignature back to Account A
    const sharedCosignature = cosignatureB;
    console.log('[Account B] <== Cosignature sent back to Account A',
        '(offchain)');

    // --- ACCOUNT A (Initiator) ---
    // Add cosignature to the transaction and rebuild payload
    transaction.cosignatures.push(sharedCosignature);
    const transactionPayloadFinal = facade.transactionFactory.static
        .attachSignature(transaction, signatureA);
    const jsonPayload = transactionPayloadFinal;
    console.log('[Account A] Ready to announce');

With the aggregate transaction built, both accounts must sign it off-chain before it can be announced.

The snippet above separates the process by machine:

  1. Account A (Initiator) signs the transaction using . It then uses which normally produces a fully announce-ready payload. In this case, however, the payload is still missing Account B’s cosignature. Account A sends this intermediate payload to Account B through an off-chain channel.

  2. Account B (Cosignatory) receives the payload and deserializes it using to reconstruct the transaction object. Account B should verify that the embedded transactions match what it expects to sign. It then cosigns using , which computes the transaction hash and produces a cosignature object. Only this cosignature is sent back to Account A.

  3. Account A receives the Account B's cosignature, adds it to the transaction object's cosignatures array, and rebuilds the payload for announcement.

Signatures in aggregate transactions

An account only signs once, even if it appears as the signer in multiple embedded transactions. In this tutorial, Account A signs the aggregate transaction, which covers both the aggregate itself and the first embedded transaction where Account A is the signer.

When all embedded transactions share the same signer (batching multiple operations from one account), cosignatures are not required. The aggregate can be announced immediately after signing, and the fee calculation does not need to reserve space for cosignatures.

Announcing the Transaction⚓︎

Now that the transaction is ready to be announced, it follows the same process as regular, non-aggregate transactions, as shown in the Transfer Transaction tutorial.

    # Announce the transaction
    announce_path = '/transactions'
    print(f'Announcing transaction to {announce_path}')
    announce_request = urllib.request.Request(
        f'{NODE_URL}{announce_path}',
        data=json_payload.encode(),
        headers={ 'Content-Type': 'application/json' },
        method='PUT'
    )
    with urllib.request.urlopen(announce_request) as response:
        print(f'  Response: {response.read().decode()}')

    # Compute hash of final transaction (with cosignatures)
    transaction_hash = facade.hash_transaction(transaction)
    // Announce the transaction
    const announcePath = '/transactions';
    console.log('Announcing transaction to', announcePath);
    const announceResponse = await fetch(`${NODE_URL}${announcePath}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: jsonPayload
    });
    console.log('  Response:', await announceResponse.text());

    // Compute hash of final transaction (with cosignatures)
    const transactionHash =
        facade.hashTransaction(transaction).toString();

Once all signatures are collected, the transaction is announced to a node using the /transactions PUT endpoint.

The node validates that all required signatures are present and valid before accepting the transaction. If validation passes, the transaction is added to the unconfirmed pool and broadcast to other nodes.

Waiting for Confirmation⚓︎

    # Wait for confirmation
    status_path = f'/transactionStatus/{transaction_hash}'
    print(f'Waiting for confirmation from {status_path}')
    for attempt in range(60):
        time.sleep(1)
        try:
            with urllib.request.urlopen(
                f'{NODE_URL}{status_path}'
            ) as response:
                status = json.loads(response.read().decode())
                print(f'  Transaction status: {status['group']}')
                if status['group'] == 'confirmed':
                    print(f'Transaction confirmed in {attempt} seconds')
                    break
                if status['group'] == 'failed':
                    print(f'Transaction failed: {status['code']}')
                    break
        except urllib.error.HTTPError as e:
            print(f'  Transaction status: unknown | Cause: ({e.msg})')
    else:
        print('Confirmation took too long.')
    // Wait for confirmation
    const statusPath = `/transactionStatus/${transactionHash}`;
    console.log('Waiting for confirmation from', statusPath);

    for (let attempt = 0; attempt < 60; attempt++) {
        await new Promise(resolve => setTimeout(resolve, 1000));
        try {
            const statusResponse = await fetch(
                `${NODE_URL}${statusPath}`);
            const status = await statusResponse.json();
            console.log('  Transaction status:', status.group);
            if (status.group === 'confirmed') {
                console.log('Transaction confirmed in', attempt,
                    'seconds');
                break;
            }
            if (status.group === 'failed') {
                console.log('Transaction failed:', status.code);
                break;
            }
        } catch (e) {
            console.log('  Transaction status: unknown | Cause:',
                e.message);
        }
    }

After announcement, the transaction status is monitored using /transactionStatus/{hash} GET.

The polling loop checks the status every second until the transaction is confirmed or fails. Once confirmed, the swap is complete and both transfers have executed.

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: 96601724742 ms since nemesis
Fetching recommended fees from /network/fees/transaction
  Fee multiplier: 100
Built aggregate transaction without signatures:
{
  "signature": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
  "signer_public_key": "3B6A27BCCEB6A42D62A3A8D02A6F0D73653215771DE243A63AC048A18B59DA29",
  "version": 3,
  "network": 152,
  "type": 16705,
  "fee": "46400",
  "deadline": "96608924742",
  "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 aggregate...
[Account A] Payload ready to share:
{
  "payload": "680100000000000068EB60FF9F330E4F86DE7FB30F4E6A398519C23C0AE2EC465524CD03C79382489EB0048EA67FA73E6C68B290BA0BE8A4DDC8DD1911F181C688E10B7EB7625B043B6A27BCCEB6A42D62A3A8D02A6F0D73653215771DE243A63AC048A18B59DA29000000000398414140B50000000000004634577E16000000C2F8137E86FA6CD2B5A6E0CBBEA5485DCC427C5F1C9AC98F7A35E84CF0A28877C00000000000000060000000000000003B6A27BCCEB6A42D62A3A8D02A6F0D73653215771DE243A63AC048A18B59DA29000000000198544198AD8BAAB80B1DC684542EC175259711AB2C41D2FE4DA9AD0000010000000000EEAFF441BA994BE780969800000000006000000000000000D04AB232742BB4AB3A1368BD4615E4E6D0224AB71A016BAF8520A332C97787370000000001985441988E1191A25A88142C2FB3F69787576E3DC713EFC1CE4DE90000010000000000C2621B75BE14136D0100000000000000"
}
[Account A] ==> Payload sent to Account B (offchain)
[Account B] Cosigning...
[Account B] Cosignature created: {
  "version": "0",
  "signer_public_key": "D04AB232742BB4AB3A1368BD4615E4E6D0224AB71A016BAF8520A332C9778737",
  "signature": "7037EBED281136D1ADAF2F1CC1721ABC58F6CFC6130C6B6864057BC77F6167AAF31271751E2E93BB0993CC1A99F2A6807C1DE87A16A9DF1C44133652F939A90F"
}
[Account B] <== Cosignature sent back to Account A (offchain)
[Account A] Ready to announce
Announcing transaction to /transactions
  Response: {"message":"packet 9 was pushed to the network via /transactions"}
Waiting for confirmation from /transactionStatus/7F8BE244311CEB28C77CBACE81469DDAD231A48C954961F0E04E38A2B7AA2016
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  Transaction status: confirmed
Transaction confirmed in 14 seconds

Key points in the output:

  • Line 10 ("signature": "0000..."): Shows all zeros initially because the transaction hasn't been signed yet.
  • Line 14 ("type": 16705): Indicates this is an aggregate_complete_transaction_v3.
  • Line 18 ("transactions"): Contains the two embedded transfers that will execute atomically.
  • Line 48 ("cosignatures": []): Initially empty. Account B's cosignature is added before announcement. Note how Account A's signature is only needed once, even though it appears as signer in both the aggregate and the first embedded transaction.
  • Line 53 ("payload": "6801..."): The transaction payload computed from the aggregate transaction and its embedded transactions.
  • Line 60 ("signature": "7037..."): Account B's cosignature for the aggregate transaction.
  • Line 66 (Waiting for confirmation ...): The hash shown in the confirmation check can be used to search for the transaction in the Symbol Testnet Explorer.

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
Collect signatures off-chain