Skip to content

Batching Transactions⚓︎

INTERMEDIATE

A complete aggregate transaction can bundle multiple transactions from a single account into one atomic operation, with one fee and one confirmation.

This is useful for distributing rewards, splitting payments, or funding several accounts at once, for example.

This tutorial shows how to batch two transfer transactions that send XYM to different recipients.

clusterAggregateAggregate Complete TransactionclusterT2Embedded Transfer 2clusterT1Embedded Transfer 1S2SignerR2Recipient 2S2->R23 XYMS1SignerR1Recipient 1S1->R15 XYM

Because all embedded transactions share the same signer, no cosignatures are needed. The aggregate can be signed and announced by a single account. For examples requiring the collection of signatures from multiple accounts, see the Complete Aggregate and Bonded Aggregate tutorials.

Prerequisites⚓︎

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

You also need an account with enough XYM to cover the transfers and the transaction fee. Although a pre-funded test account is provided for convenience, it is not maintained and may run out of funds at any time.

To use your own account, complete the following steps:

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

Full Code⚓︎

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

import json
import os
import time
import urllib.request

from symbolchain.CryptoTypes import PrivateKey
from symbolchain.facade.SymbolFacade import SymbolFacade
from symbolchain.symbol.IdGenerator import generate_mosaic_alias_id
from symbolchain.symbol.Network import Address

NODE_URL = os.getenv('NODE_URL', 'https://reference.symboltest.net:3001')
print(f'Using node {NODE_URL}')


# Helper function to announce a transaction
def announce_transaction(payload, label):
    print(f'Announcing {label} to /transactions')
    request = urllib.request.Request(
        f'{NODE_URL}/transactions',
        data=payload.encode(),
        headers={'Content-Type': 'application/json'},
        method='PUT'
    )
    with urllib.request.urlopen(request) as announce_response:
        print(f'  Response: {announce_response.read().decode()}')


# Helper function to wait for transaction confirmation
def wait_for_confirmation(tx_hash, label):
    print(f'Waiting for {label} confirmation...')
    for attempt in range(60):
        time.sleep(1)
        try:
            url = f'{NODE_URL}/transactionStatus/{tx_hash}'
            with urllib.request.urlopen(url) as confirm_response:
                status = json.loads(confirm_response.read().decode())
                print(f'  Transaction status: {status["group"]}')
                if status['group'] == 'confirmed':
                    print(f'{label} confirmed in {attempt} seconds')
                    return
                if status['group'] == 'failed':
                    raise RuntimeError(
                        f'{label} failed: {status["code"]}')
        except urllib.error.HTTPError:
            print('  Transaction status: unknown')
    raise TimeoutError(f'{label} not confirmed after 60 seconds')


SIGNER_PRIVATE_KEY = os.getenv(
    'SIGNER_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000000')
signer_key_pair = SymbolFacade.KeyPair(
    PrivateKey(SIGNER_PRIVATE_KEY))

facade = SymbolFacade('testnet')
signer_address = facade.network.public_key_to_address(
    signer_key_pair.public_key)
print(f'Signer public key: {signer_key_pair.public_key}')
print(f'Signer address: {signer_address}')

RECIPIENT_1 = os.getenv(
    'RECIPIENT_1', 'TCWYXKVYBMO4NBCUF3AXKJMXCGVSYQOS7ZG2TLI')
RECIPIENT_2 = os.getenv(
    'RECIPIENT_2', 'TCD4NC5VIE2EEB3BCV5JRLBNJXYDW5Q5JK547MI')
recipient1_hex = Address(RECIPIENT_1).bytes.hex().upper()
recipient2_hex = Address(RECIPIENT_2).bytes.hex().upper()
print(f'Recipient 1: {RECIPIENT_1} ({recipient1_hex})')
print(f'Recipient 2: {RECIPIENT_2} ({recipient2_hex})')

try:
    # 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_multiplier = response_json['medianFeeMultiplier']
        minimum_multiplier = response_json['minFeeMultiplier']
        fee_multiplier = max(median_multiplier, minimum_multiplier)
        print(f'  Fee multiplier: {fee_multiplier}')

    # Embedded tx 1: Send 5 XYM to Recipient 1
    xym_mosaic_id = generate_mosaic_alias_id('symbol.xym')
    embedded_tx_1 = facade.create_embedded_transaction_from_descriptor(
        {
            'type': 'transfer_transaction_v1',
            'recipient_address': Address(RECIPIENT_1),
            'mosaics': [{
                'mosaic_id': xym_mosaic_id,
                'amount': 5_000_000  # 5 XYM
            }]
        },
        signer_key_pair.public_key)

    # Embedded tx 2: Send 3 XYM to Recipient 2
    embedded_tx_2 = facade.create_embedded_transaction_from_descriptor(
        {
            'type': 'transfer_transaction_v1',
            'recipient_address': Address(RECIPIENT_2),
            'mosaics': [{
                'mosaic_id': xym_mosaic_id,
                'amount': 3_000_000  # 3 XYM
            }]
        },
        signer_key_pair.public_key)

    # Build the aggregate transaction
    embedded_transactions = [embedded_tx_1, embedded_tx_2]
    transaction = facade.create_transaction_from_descriptor(
        {
            'type': 'aggregate_complete_transaction_v3',
            'transactions_hash':
                facade.hash_embedded_transactions(embedded_transactions),
            'transactions': embedded_transactions
        },
        signer_key_pair.public_key,
        fee_multiplier,
        2 * 60 * 60)
    print('Built aggregate transaction:')
    print(json.dumps(transaction.to_json(), indent=2))

    # Sign transaction and generate final payload
    signature = facade.sign_transaction(signer_key_pair, transaction)
    json_payload = facade.transaction_factory.attach_signature(
        transaction, signature)

    # Announce the transaction
    announce_transaction(json_payload, 'transaction')

    # Wait for confirmation
    transaction_hash = facade.hash_transaction(transaction)
    print(f'Transaction hash: {transaction_hash}')
    wait_for_confirmation(transaction_hash, 'transaction')

except Exception as e:
    print(e)

Download source

import { PrivateKey } from 'symbol-sdk';
import {
    Address,
    SymbolFacade,
    descriptors,
    generateMosaicAliasId,
    models
} from 'symbol-sdk/symbol';

const NODE_URL = process.env.NODE_URL ||
    'https://reference.symboltest.net:3001';
console.log('Using node', NODE_URL);

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

// Helper function to wait for transaction confirmation
async function waitForConfirmation(transactionHash, label) {
    console.log(`Waiting for ${label} confirmation...`);
    for (let attempt = 0; 60 > attempt; attempt++) {
        await new Promise(resolve => { setTimeout(resolve, 1000); });
        const response = await fetch(
            `${NODE_URL}/transactionStatus/${transactionHash}`);
        if (!response.ok) {
            if (404 === response.status) {
                console.log('  Transaction status: unknown');
                continue;
            }
            throw new Error(`HTTP ${response.status}`);
        }
        const status = await response.json();
        console.log('  Transaction status:', status.group);
        if ('confirmed' === status.group) {
            console.log(`${label} confirmed in`, attempt, 'seconds');
            return;
        }
        if ('failed' === status.group)
            throw new Error(`${label} failed: ${status.code}`);
    }
    throw new Error(`${label} not confirmed after 60 seconds`);
}


const SIGNER_PRIVATE_KEY = process.env.SIGNER_PRIVATE_KEY ||
    '0000000000000000000000000000000000000000000000000000000000000000';
const signerKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(SIGNER_PRIVATE_KEY));

const facade = new SymbolFacade('testnet');
const signerAddress = facade.network.publicKeyToAddress(
    signerKeyPair.publicKey);
console.log('Signer public key:', signerKeyPair.publicKey.toString());
console.log('Signer address:', signerAddress.toString());

const RECIPIENT_1 = process.env.RECIPIENT_1 ||
    'TCWYXKVYBMO4NBCUF3AXKJMXCGVSYQOS7ZG2TLI';
const RECIPIENT_2 = process.env.RECIPIENT_2 ||
    'TCD4NC5VIE2EEB3BCV5JRLBNJXYDW5Q5JK547MI';
const recipient1Hex = Buffer.from(
    new Address(RECIPIENT_1).bytes).toString('hex').toUpperCase();
const recipient2Hex = Buffer.from(
    new Address(RECIPIENT_2).bytes).toString('hex').toUpperCase();
console.log(`Recipient 1: ${RECIPIENT_1} (${recipient1Hex})`);
console.log(`Recipient 2: ${RECIPIENT_2} (${recipient2Hex})`);

try {
    // 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 medianMultiplier = feeJSON.medianFeeMultiplier;
    const minimumMultiplier = feeJSON.minFeeMultiplier;
    const feeMultiplier = Math.max(medianMultiplier, minimumMultiplier);
    console.log('  Fee multiplier:', feeMultiplier);

    // Embedded tx 1: Send 5 XYM to Recipient 1
    const xymMosaicId = generateMosaicAliasId('symbol.xym');
    const embeddedTx1 =
        facade.createEmbeddedTransactionFromTypedDescriptor(
            new descriptors.TransferTransactionV1Descriptor(
                new Address(RECIPIENT_1),
                [
                    new descriptors.UnresolvedMosaicDescriptor(
                        xymMosaicId,
                        new models.Amount(5_000_000n)) // 5 XYM
                ],
                undefined),
            signerKeyPair.publicKey);

    // Embedded tx 2: Send 3 XYM to Recipient 2
    const embeddedTx2 =
        facade.createEmbeddedTransactionFromTypedDescriptor(
            new descriptors.TransferTransactionV1Descriptor(
                new Address(RECIPIENT_2),
                [
                    new descriptors.UnresolvedMosaicDescriptor(
                        xymMosaicId,
                        new models.Amount(3_000_000n)) // 3 XYM
                ],
                undefined),
            signerKeyPair.publicKey);

    // Build the aggregate transaction
    const embeddedTransactions = [embeddedTx1, embeddedTx2];
    const transaction = facade.createTransactionFromTypedDescriptor(
        new descriptors.AggregateCompleteTransactionV3Descriptor(
            facade.static.hashEmbeddedTransactions(embeddedTransactions),
            embeddedTransactions,
            undefined),
        signerKeyPair.publicKey,
        feeMultiplier,
        2 * 60 * 60);
    console.log('Built aggregate transaction:');
    console.log(JSON.stringify(transaction.toJson(), null, 2));

    // Sign transaction and generate final payload
    const signature = facade.signTransaction(
        signerKeyPair, transaction);
    const jsonPayload = facade.transactionFactory.static
        .attachSignature(transaction, signature);

    // Announce the transaction
    await announceTransaction(jsonPayload, 'transaction');

    // Wait for confirmation
    const transactionHash =
        facade.hashTransaction(transaction).toString();
    console.log('Transaction hash:', transactionHash);
    await waitForConfirmation(transactionHash, 'transaction');

} catch (e) {
    console.error(e.message, '| Cause:', e.cause?.code ?? 'unknown');
}

Download source

//JAVA 21+
//DEPS org.symbol:symbol-sdk:3.3.1

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

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

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

public final class TransactionBatching {
    private static final ObjectMapper JSON_MAPPER = new ObjectMapper();

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

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

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

    private void announceTransaction(
        final String payload,
        final String label
    ) throws IOException, InterruptedException {
        System.out.printf("Announcing %s to /transactions%n", label);
        final HttpRequest request = HttpRequest.newBuilder(
            URI.create(nodeUrl + "/transactions"))
            .header("Content-Type", "application/json")
            .PUT(HttpRequest.BodyPublishers.ofString(payload))
            .build();
        final HttpResponse<String> response = HTTP_CLIENT.send(
            request, BodyHandlers.ofString());
        System.out.printf("  Response: %s%n", response.body());
    }

    private void waitForConfirmation(
        final String transactionHash,
        final String label
    ) throws IOException, InterruptedException {
        System.out.printf("Waiting for %s confirmation...%n", label);
        for (int attempt = 0; 60 > attempt; ++attempt) {
            Thread.sleep(1000);
            final String statusPath =
                "/transactionStatus/" + transactionHash;
            final HttpRequest statusRequest = HttpRequest.newBuilder(
                URI.create(nodeUrl + statusPath)).GET().build();
            final HttpResponse<String> statusResponse = HTTP_CLIENT
                .send(statusRequest, BodyHandlers.ofString());
            if (404 == statusResponse.statusCode()) {
                System.out.println("  Transaction status: unknown");
                continue;
            }
            if (2 != statusResponse.statusCode() / 100)
                throw new IOException(
                    "HTTP " + statusResponse.statusCode());

            final JsonNode status =
                JSON_MAPPER.readTree(statusResponse.body());
            final String group = status.get("group").asText();
            System.out.printf("  Transaction status: %s%n", group);
            if ("confirmed".equals(group)) {
                System.out.printf("%s confirmed in %d seconds%n",
                    label, attempt);
                return;
            }
            if ("failed".equals(group))
                throw new IOException(String.format("%s failed: %s",
                    label, status.get("code").asText()));
        }
        throw new IOException(String.format(
            "%s not confirmed after 60 seconds", label));
    }

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

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


        final String signerPrivateKey = System.getenv().getOrDefault(
            "SIGNER_PRIVATE_KEY", "0".repeat(64));
        final KeyPair signerKeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(signerPrivateKey));

        final Address signerAddress = facade.network.publicKeyToAddress(
            signerKeyPair.getPublicKey());
        System.out.printf("Signer public key: %s%n",
            signerKeyPair.getPublicKey());
        System.out.printf("Signer address: %s%n", signerAddress);

        final String recipient1String = System.getenv().getOrDefault(
            "RECIPIENT_1", "TCWYXKVYBMO4NBCUF3AXKJMXCGVSYQOS7ZG2TLI");
        final String recipient2String = System.getenv().getOrDefault(
            "RECIPIENT_2", "TCD4NC5VIE2EEB3BCV5JRLBNJXYDW5Q5JK547MI");
        final Address recipient1 = new Address(recipient1String);
        final Address recipient2 = new Address(recipient2String);
        final String recipient1Hex = HexFormat.of().formatHex(
            recipient1.bytes()).toUpperCase();
        final String recipient2Hex = HexFormat.of().formatHex(
            recipient2.bytes()).toUpperCase();
        System.out.printf("Recipient 1: %s (%s)%n",
            recipient1String, recipient1Hex);
        System.out.printf("Recipient 2: %s (%s)%n",
            recipient2String, recipient2Hex);

        // Fetch recommended fees
        final String feePath = "/network/fees/transaction";
        System.out.printf("Fetching recommended fees from %s%n", feePath);
        final HttpRequest feeRequest = HttpRequest.newBuilder(
            URI.create(nodeUrl + feePath)).GET().build();
        final HttpResponse<String> feeResponse = HTTP_CLIENT.send(
            feeRequest, BodyHandlers.ofString());
        final JsonNode feeJSON = JSON_MAPPER.readTree(feeResponse.body());
        final long medianMultiplier =
            feeJSON.get("medianFeeMultiplier").asLong();
        final long minimumMultiplier =
            feeJSON.get("minFeeMultiplier").asLong();
        final long feeMultiplier = Math.max(
            medianMultiplier, minimumMultiplier);
        System.out.printf("  Fee multiplier: %d%n", feeMultiplier);

        // Embedded tx 1: Send 5 XYM to Recipient 1
        final long xymMosaicId = IdGenerator.generateMosaicAliasId(
            "symbol.xym");
        final EmbeddedTransaction embeddedTx1 =
            facade.createEmbeddedTransactionFromTypedDescriptor(
                new TransferTransactionV1Descriptor(
                    recipient1,
                    List.of(new UnresolvedMosaicDescriptor(
                        new UnresolvedMosaicId(xymMosaicId),
                        new Amount(5_000_000))), // 5 XYM
                    null),
                signerKeyPair.getPublicKey());

        // Embedded tx 2: Send 3 XYM to Recipient 2
        final EmbeddedTransaction embeddedTx2 =
            facade.createEmbeddedTransactionFromTypedDescriptor(
                new TransferTransactionV1Descriptor(
                    recipient2,
                    List.of(new UnresolvedMosaicDescriptor(
                        new UnresolvedMosaicId(xymMosaicId),
                        new Amount(3_000_000))), // 3 XYM
                    null),
                signerKeyPair.getPublicKey());

        // Build the aggregate transaction
        final List<EmbeddedTransaction> embeddedTransactions =
            List.of(embeddedTx1, embeddedTx2);
        final Transaction transaction =
            facade.createTransactionFromTypedDescriptor(
                new AggregateCompleteTransactionV3Descriptor(
                    SymbolFacade.hashEmbeddedTransactions(
                        embeddedTransactions),
                    embeddedTransactions,
                    null),
                signerKeyPair.getPublicKey(),
                feeMultiplier,
                2 * 60 * 60);
        System.out.println("Built aggregate transaction:");
        System.out.println(JSON_MAPPER.writerWithDefaultPrettyPrinter()
            .writeValueAsString(transaction.toJson()));

        // Sign transaction and generate final payload
        final CryptoTypes.Signature signature = facade.signTransaction(
            signerKeyPair, transaction);
        final String jsonPayload = SymbolTransactionFactory
            .attachSignature(transaction, signature);

        // Announce the transaction
        announceTransaction(jsonPayload, "transaction");

        // Wait for confirmation
        final String transactionHash =
            facade.hashTransaction(transaction).toString();
        System.out.printf("Transaction hash: %s%n", transactionHash);
        waitForConfirmation(transactionHash, "transaction");
    }
}

Download source

Code Explanation⚓︎

Setting Up the Account⚓︎

SIGNER_PRIVATE_KEY = os.getenv(
    'SIGNER_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000000')
signer_key_pair = SymbolFacade.KeyPair(
    PrivateKey(SIGNER_PRIVATE_KEY))

facade = SymbolFacade('testnet')
signer_address = facade.network.public_key_to_address(
    signer_key_pair.public_key)
print(f'Signer public key: {signer_key_pair.public_key}')
print(f'Signer address: {signer_address}')

RECIPIENT_1 = os.getenv(
    'RECIPIENT_1', 'TCWYXKVYBMO4NBCUF3AXKJMXCGVSYQOS7ZG2TLI')
RECIPIENT_2 = os.getenv(
    'RECIPIENT_2', 'TCD4NC5VIE2EEB3BCV5JRLBNJXYDW5Q5JK547MI')
recipient1_hex = Address(RECIPIENT_1).bytes.hex().upper()
recipient2_hex = Address(RECIPIENT_2).bytes.hex().upper()
print(f'Recipient 1: {RECIPIENT_1} ({recipient1_hex})')
print(f'Recipient 2: {RECIPIENT_2} ({recipient2_hex})')
const SIGNER_PRIVATE_KEY = process.env.SIGNER_PRIVATE_KEY ||
    '0000000000000000000000000000000000000000000000000000000000000000';
const signerKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(SIGNER_PRIVATE_KEY));

const facade = new SymbolFacade('testnet');
const signerAddress = facade.network.publicKeyToAddress(
    signerKeyPair.publicKey);
console.log('Signer public key:', signerKeyPair.publicKey.toString());
console.log('Signer address:', signerAddress.toString());

const RECIPIENT_1 = process.env.RECIPIENT_1 ||
    'TCWYXKVYBMO4NBCUF3AXKJMXCGVSYQOS7ZG2TLI';
const RECIPIENT_2 = process.env.RECIPIENT_2 ||
    'TCD4NC5VIE2EEB3BCV5JRLBNJXYDW5Q5JK547MI';
const recipient1Hex = Buffer.from(
    new Address(RECIPIENT_1).bytes).toString('hex').toUpperCase();
const recipient2Hex = Buffer.from(
    new Address(RECIPIENT_2).bytes).toString('hex').toUpperCase();
console.log(`Recipient 1: ${RECIPIENT_1} (${recipient1Hex})`);
console.log(`Recipient 2: ${RECIPIENT_2} (${recipient2Hex})`);
        final String signerPrivateKey = System.getenv().getOrDefault(
            "SIGNER_PRIVATE_KEY", "0".repeat(64));
        final KeyPair signerKeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(signerPrivateKey));

        final Address signerAddress = facade.network.publicKeyToAddress(
            signerKeyPair.getPublicKey());
        System.out.printf("Signer public key: %s%n",
            signerKeyPair.getPublicKey());
        System.out.printf("Signer address: %s%n", signerAddress);

        final String recipient1String = System.getenv().getOrDefault(
            "RECIPIENT_1", "TCWYXKVYBMO4NBCUF3AXKJMXCGVSYQOS7ZG2TLI");
        final String recipient2String = System.getenv().getOrDefault(
            "RECIPIENT_2", "TCD4NC5VIE2EEB3BCV5JRLBNJXYDW5Q5JK547MI");
        final Address recipient1 = new Address(recipient1String);
        final Address recipient2 = new Address(recipient2String);
        final String recipient1Hex = HexFormat.of().formatHex(
            recipient1.bytes()).toUpperCase();
        final String recipient2Hex = HexFormat.of().formatHex(
            recipient2.bytes()).toUpperCase();
        System.out.printf("Recipient 1: %s (%s)%n",
            recipient1String, recipient1Hex);
        System.out.printf("Recipient 2: %s (%s)%n",
            recipient2String, recipient2Hex);

The signer account is loaded from the SIGNER_PRIVATE_KEY environment variable. If not provided, a test key is used as default.

The two recipient addresses are loaded from the RECIPIENT_1 and RECIPIENT_2 environment variables. If not provided, test addresses are used as defaults.

    # 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_multiplier = response_json['medianFeeMultiplier']
        minimum_multiplier = response_json['minFeeMultiplier']
        fee_multiplier = max(median_multiplier, minimum_multiplier)
        print(f'  Fee multiplier: {fee_multiplier}')
    // 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 medianMultiplier = feeJSON.medianFeeMultiplier;
    const minimumMultiplier = feeJSON.minFeeMultiplier;
    const feeMultiplier = Math.max(medianMultiplier, minimumMultiplier);
    console.log('  Fee multiplier:', feeMultiplier);
        // Fetch recommended fees
        final String feePath = "/network/fees/transaction";
        System.out.printf("Fetching recommended fees from %s%n", feePath);
        final HttpRequest feeRequest = HttpRequest.newBuilder(
            URI.create(nodeUrl + feePath)).GET().build();
        final HttpResponse<String> feeResponse = HTTP_CLIENT.send(
            feeRequest, BodyHandlers.ofString());
        final JsonNode feeJSON = JSON_MAPPER.readTree(feeResponse.body());
        final long medianMultiplier =
            feeJSON.get("medianFeeMultiplier").asLong();
        final long minimumMultiplier =
            feeJSON.get("minFeeMultiplier").asLong();
        final long feeMultiplier = Math.max(
            medianMultiplier, minimumMultiplier);
        System.out.printf("  Fee multiplier: %d%n", feeMultiplier);

Recommended fees are fetched from /network/fees/transaction GET, following the process described in the Transfer Transaction tutorial.

Creating Embedded Transactions⚓︎

    # Embedded tx 1: Send 5 XYM to Recipient 1
    xym_mosaic_id = generate_mosaic_alias_id('symbol.xym')
    embedded_tx_1 = facade.create_embedded_transaction_from_descriptor(
        {
            'type': 'transfer_transaction_v1',
            'recipient_address': Address(RECIPIENT_1),
            'mosaics': [{
                'mosaic_id': xym_mosaic_id,
                'amount': 5_000_000  # 5 XYM
            }]
        },
        signer_key_pair.public_key)

    # Embedded tx 2: Send 3 XYM to Recipient 2
    embedded_tx_2 = facade.create_embedded_transaction_from_descriptor(
        {
            'type': 'transfer_transaction_v1',
            'recipient_address': Address(RECIPIENT_2),
            'mosaics': [{
                'mosaic_id': xym_mosaic_id,
                'amount': 3_000_000  # 3 XYM
            }]
        },
        signer_key_pair.public_key)
    // Embedded tx 1: Send 5 XYM to Recipient 1
    const xymMosaicId = generateMosaicAliasId('symbol.xym');
    const embeddedTx1 =
        facade.createEmbeddedTransactionFromTypedDescriptor(
            new descriptors.TransferTransactionV1Descriptor(
                new Address(RECIPIENT_1),
                [
                    new descriptors.UnresolvedMosaicDescriptor(
                        xymMosaicId,
                        new models.Amount(5_000_000n)) // 5 XYM
                ],
                undefined),
            signerKeyPair.publicKey);

    // Embedded tx 2: Send 3 XYM to Recipient 2
    const embeddedTx2 =
        facade.createEmbeddedTransactionFromTypedDescriptor(
            new descriptors.TransferTransactionV1Descriptor(
                new Address(RECIPIENT_2),
                [
                    new descriptors.UnresolvedMosaicDescriptor(
                        xymMosaicId,
                        new models.Amount(3_000_000n)) // 3 XYM
                ],
                undefined),
            signerKeyPair.publicKey);
        // Embedded tx 1: Send 5 XYM to Recipient 1
        final long xymMosaicId = IdGenerator.generateMosaicAliasId(
            "symbol.xym");
        final EmbeddedTransaction embeddedTx1 =
            facade.createEmbeddedTransactionFromTypedDescriptor(
                new TransferTransactionV1Descriptor(
                    recipient1,
                    List.of(new UnresolvedMosaicDescriptor(
                        new UnresolvedMosaicId(xymMosaicId),
                        new Amount(5_000_000))), // 5 XYM
                    null),
                signerKeyPair.getPublicKey());

        // Embedded tx 2: Send 3 XYM to Recipient 2
        final EmbeddedTransaction embeddedTx2 =
            facade.createEmbeddedTransactionFromTypedDescriptor(
                new TransferTransactionV1Descriptor(
                    recipient2,
                    List.of(new UnresolvedMosaicDescriptor(
                        new UnresolvedMosaicId(xymMosaicId),
                        new Amount(3_000_000))), // 3 XYM
                    null),
                signerKeyPair.getPublicKey());

Each transfer is created as an embedded transaction that will be wrapped inside the aggregate. All embedded transactions use the same because they all originate from the same account.

The example creates two transfer transactions:

  • The first transfer sends 5 XYM to Recipient 1.
  • The second transfer sends 3 XYM to Recipient 2.

The is still required on each embedded transaction, even when all share the same signer.

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

Batching other transaction types

Although this example batches transfer transactions, any transaction type can be embedded within an aggregate (except other aggregates). For example, you could batch mosaic creation with a namespace alias registration in a single atomic operation.

Building the Aggregate Transaction⚓︎

    # Build the aggregate transaction
    embedded_transactions = [embedded_tx_1, embedded_tx_2]
    transaction = facade.create_transaction_from_descriptor(
        {
            'type': 'aggregate_complete_transaction_v3',
            'transactions_hash':
                facade.hash_embedded_transactions(embedded_transactions),
            'transactions': embedded_transactions
        },
        signer_key_pair.public_key,
        fee_multiplier,
        2 * 60 * 60)
    print('Built aggregate transaction:')
    print(json.dumps(transaction.to_json(), indent=2))
    // Build the aggregate transaction
    const embeddedTransactions = [embeddedTx1, embeddedTx2];
    const transaction = facade.createTransactionFromTypedDescriptor(
        new descriptors.AggregateCompleteTransactionV3Descriptor(
            facade.static.hashEmbeddedTransactions(embeddedTransactions),
            embeddedTransactions,
            undefined),
        signerKeyPair.publicKey,
        feeMultiplier,
        2 * 60 * 60);
    console.log('Built aggregate transaction:');
    console.log(JSON.stringify(transaction.toJson(), null, 2));
        // Build the aggregate transaction
        final List<EmbeddedTransaction> embeddedTransactions =
            List.of(embeddedTx1, embeddedTx2);
        final Transaction transaction =
            facade.createTransactionFromTypedDescriptor(
                new AggregateCompleteTransactionV3Descriptor(
                    SymbolFacade.hashEmbeddedTransactions(
                        embeddedTransactions),
                    embeddedTransactions,
                    null),
                signerKeyPair.getPublicKey(),
                feeMultiplier,
                2 * 60 * 60);
        System.out.println("Built aggregate transaction:");
        System.out.println(JSON_MAPPER.writerWithDefaultPrettyPrinter()
            .writeValueAsString(transaction.toJson()));

The aggregate transaction is created from the transaction's descriptor, which contains:

also receives the signer public key, fee multiplier, and deadline duration. The signer signs the aggregate and pays the transaction fee.

calculates the fee based on the aggregate's total size. Since no cosignatures are needed, no extra cosignature count is provided.

Signing and Announcing⚓︎

    # Sign transaction and generate final payload
    signature = facade.sign_transaction(signer_key_pair, transaction)
    json_payload = facade.transaction_factory.attach_signature(
        transaction, signature)

    # Announce the transaction
    announce_transaction(json_payload, 'transaction')
    // Sign transaction and generate final payload
    const signature = facade.signTransaction(
        signerKeyPair, transaction);
    const jsonPayload = facade.transactionFactory.static
        .attachSignature(transaction, signature);

    // Announce the transaction
    await announceTransaction(jsonPayload, 'transaction');
        // Sign transaction and generate final payload
        final CryptoTypes.Signature signature = facade.signTransaction(
            signerKeyPair, transaction);
        final String jsonPayload = SymbolTransactionFactory
            .attachSignature(transaction, signature);

        // Announce the transaction
        announceTransaction(jsonPayload, "transaction");

The aggregate is signed with and serialized into a payload using . The signed payload is then announced to a node using the /transactions PUT endpoint, following the same process as regular transactions described in the Transfer Transaction tutorial.

Waiting for Confirmation⚓︎

    # Wait for confirmation
    transaction_hash = facade.hash_transaction(transaction)
    print(f'Transaction hash: {transaction_hash}')
    wait_for_confirmation(transaction_hash, 'transaction')
    // Wait for confirmation
    const transactionHash =
        facade.hashTransaction(transaction).toString();
    console.log('Transaction hash:', transactionHash);
    await waitForConfirmation(transactionHash, 'transaction');
        // Wait for confirmation
        final String transactionHash =
            facade.hashTransaction(transaction).toString();
        System.out.printf("Transaction hash: %s%n", transactionHash);
        waitForConfirmation(transactionHash, "transaction");

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.

Output⚓︎

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

Using node https://reference.symboltest.net:3001
Signer public key: 3B6A27BCCEB6A42D62A3A8D02A6F0D73653215771DE243A63AC048A18B59DA29
Signer address: TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I
Recipient 1: TCWYXKVYBMO4NBCUF3AXKJMXCGVSYQOS7ZG2TLI (98AD8BAAB80B1DC684542EC175259711AB2C41D2FE4DA9AD)
Recipient 2: TCD4NC5VIE2EEB3BCV5JRLBNJXYDW5Q5JK547MI (9887C68BB54134420761157A98AC2D4DF03B761D4ABBCFB1)
Fetching recommended fees from /network/fees/transaction
  Fee multiplier: 100
Built aggregate transaction:
{
  "signature": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
  "signer_public_key": "3B6A27BCCEB6A42D62A3A8D02A6F0D73653215771DE243A63AC048A18B59DA29",
  "version": 3,
  "network": 152,
  "type": 16705,
  "fee": "36000",
  "deadline": "107382791542",
  "transactions_hash": "006E8D5F5AC08E7D8EEAB2265569A68FF2921722DCE69D7AA61684A8EE5722C0",
  "transactions": [
    {
      "signer_public_key": "3B6A27BCCEB6A42D62A3A8D02A6F0D73653215771DE243A63AC048A18B59DA29",
      "version": 1,
      "network": 152,
      "type": 16724,
      "recipient_address": "98AD8BAAB80B1DC684542EC175259711AB2C41D2FE4DA9AD",
      "mosaics": [
        {
          "mosaic_id": "16666583871264174062",
          "amount": "5000000"
        }
      ],
      "message": ""
    },
    {
      "signer_public_key": "3B6A27BCCEB6A42D62A3A8D02A6F0D73653215771DE243A63AC048A18B59DA29",
      "version": 1,
      "network": 152,
      "type": 16724,
      "recipient_address": "9887C68BB54134420761157A98AC2D4DF03B761D4ABBCFB1",
      "mosaics": [
        {
          "mosaic_id": "16666583871264174062",
          "amount": "3000000"
        }
      ],
      "message": ""
    }
  ],
  "cosignatures": []
}
Announcing transaction to /transactions
  Response: {"message":"packet 9 was pushed to the network via /transactions"}
Transaction hash: 5390D8FAC80B9F76275DA857A3A18B6704FEA8B84026C2893F0041326B8C23D2
Waiting for transaction confirmation...
  Transaction status: unconfirmed
  Transaction status: confirmed
transaction confirmed in 6 seconds

Key points in the output:

  • Line 14 ("type": 16705): Identifies this as an AggregateCompleteTransactionV3.
  • Lines 24 and 38 ("recipient_address"): The two embedded transfers target different accounts. These are the hex-encoded forms of the Base32 addresses printed on lines 4-5.
  • Lines 27-28 and 41-42 ("mosaic_id", "amount"): Each transfer sends XYM (mosaic alias ID 16666583871264174062). The amounts 5000000 and 3000000 correspond to 5 and 3 XYM because this mosaic has divisibility 6.
  • Line 48 ("cosignatures": []): Empty because all embedded transactions share the same signer. No additional signatures are required.

The aggregate transaction executes atomically: both recipients receive their XYM transfers, or neither does.

The transaction hash printed in the output (line 52) can be used to search for the transaction in the Symbol Testnet Explorer.

Conclusion⚓︎

This tutorial showed how to:

Step Related documentation
Create embedded transactions
TransferTransactionV1
Build the aggregate
AggregateCompleteTransactionV3
Sign and announce

Next Steps⚓︎