Skip to content

Signing a Transaction from a Multisignature Account⚓︎

INTERMEDIATE

This tutorial transfers 1 XYM from an account to itself, mirroring the Transfer Transaction tutorial.

However, in this case, the source account is a multisignature account, also called multisig, and therefore it cannot initiate or sign transactions on its own. Instead, it relies on one of its cosignatory accounts to create transactions and sign them on its behalf.

This tutorial uses the multisig configuration created in the Configuring a Multisignature Account tutorial, with Cosignatory 0 initiating and signing the transaction:

Multisignature TreeMultisignature AccountMultisignature AccountCosignatory 0Cosignatory 0Cosignatory 0->Multisignature AccountCosignatory 1Cosignatory 1Cosignatory 1->Multisignature Account

Prerequisites⚓︎

Before you start, make sure to:

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

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

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


MULTISIG_PRIVATE_KEY = os.getenv(
    'MULTISIG_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000001')
multisig_key_pair = SymbolFacade.KeyPair(
    PrivateKey(MULTISIG_PRIVATE_KEY))
print(f'Multisig public key: {multisig_key_pair.public_key}')
COSIGNATORY0_PRIVATE_KEY = os.getenv(
    'COSIGNATORY0_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000002')
cosignatory_key_pair = SymbolFacade.KeyPair(
    PrivateKey(COSIGNATORY0_PRIVATE_KEY))
print(f'Cosignatory public key: {cosignatory_key_pair.public_key}')

facade = SymbolFacade('testnet')

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

    # Build the embedded transfer transaction
    transfer_transaction = (
        facade.create_embedded_transaction_from_descriptor(
            {
                'type': 'transfer_transaction_v1',
                'recipient_address':
                    facade.network.public_key_to_address(
                        multisig_key_pair.public_key),
                'mosaics': [{
                    'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
                    'amount': 1_000_000  # 1 XYM
                }]
            },
            multisig_key_pair.public_key))

    # Build the wrapper aggregate transaction
    transaction = facade.create_transaction_from_descriptor(
        {
            'type': 'aggregate_complete_transaction_v3',
            'transactions_hash': facade.hash_embedded_transactions(
                [transfer_transaction]),
            'transactions': [transfer_transaction]
        },
        cosignatory_key_pair.public_key,
        fee_multiplier,
        2 * 60 * 60)


    # Sign the aggregate transaction using the cosignatory's signature
    json_payload = facade.transaction_factory.attach_signature(
        transaction,
        facade.sign_transaction(cosignatory_key_pair, transaction))
    print('Built transaction:')
    print(json.dumps(transaction.to_json(), indent=2))

    # Announce the transaction
    transaction_hash = facade.hash_transaction(transaction)
    print(f'Transaction hash: {transaction_hash}')
    announce_transaction(json_payload, 'transaction')

    # Wait for confirmation
    wait_for_confirmation(transaction_hash, 'transaction')

except urllib.error.URLError as e:
    print(e.reason)

Download source

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

const 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 MULTISIG_PRIVATE_KEY = process.env.MULTISIG_PRIVATE_KEY || (
    '0000000000000000000000000000000000000000000000000000000000000001');
const multisigKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(MULTISIG_PRIVATE_KEY));
console.log(`Multisig public key: ${multisigKeyPair.publicKey}`);
const COSIGNATORY0_PRIVATE_KEY = process.env.COSIGNATORY0_PRIVATE_KEY ||
    '0000000000000000000000000000000000000000000000000000000000000002';
const cosignatoryKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(COSIGNATORY0_PRIVATE_KEY));
console.log(`Cosignatory public key: ${cosignatoryKeyPair.publicKey}`);

const facade = new SymbolFacade('testnet');

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

    // Build the embedded transfer transaction
    const transferTransaction =
        facade.createEmbeddedTransactionFromTypedDescriptor(
            new descriptors.TransferTransactionV1Descriptor(
                facade.network.publicKeyToAddress(
                    multisigKeyPair.publicKey),
                [
                    new descriptors.UnresolvedMosaicDescriptor(
                        generateMosaicAliasId('symbol.xym'),
                        new models.Amount(1_000_000n)) // 1 XYM
                ],
                undefined),
            multisigKeyPair.publicKey);

    // Build the wrapper aggregate transaction
    const transaction = facade.createTransactionFromTypedDescriptor(
        new descriptors.AggregateCompleteTransactionV3Descriptor(
            facade.static.hashEmbeddedTransactions([transferTransaction]),
            [transferTransaction],
            undefined),
        cosignatoryKeyPair.publicKey,
        feeMultiplier,
        2 * 60 * 60);

    // Sign the aggregate transaction using the cosignatory's signature
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction,
        facade.signTransaction(cosignatoryKeyPair, transaction));
    console.log('Built transaction:');
    console.dir(transaction.toJson(), { colors: true });

    // Announce the transaction
    const transactionHash =
        facade.hashTransaction(transaction).toString();
    console.log('Transaction hash:', transactionHash);
    await announceTransaction(jsonPayload, 'transaction');

    // Wait for confirmation
    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.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.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 SignMultisig {
    private static final ObjectMapper JSON_MAPPER = new ObjectMapper();

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

    private final String nodeUrl = "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 SignMultisig().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 multisigPrivateKey = System.getenv().getOrDefault(
            "MULTISIG_PRIVATE_KEY", "%064X".formatted(1));

        final KeyPair multisigKeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(multisigPrivateKey));
        System.out.printf("Multisig public key: %s%n",
            multisigKeyPair.getPublicKey());
        final String cosignatory0PrivateKey = System.getenv().getOrDefault(
            "COSIGNATORY0_PRIVATE_KEY", "%064X".formatted(2));
        final KeyPair cosignatoryKeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(cosignatory0PrivateKey));
        System.out.printf("Cosignatory public key: %s%n",
            cosignatoryKeyPair.getPublicKey());


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

        // Build the embedded transfer transaction
        final EmbeddedTransaction transferTransaction =
            facade.createEmbeddedTransactionFromTypedDescriptor(
                new TransferTransactionV1Descriptor(
                    facade.network.publicKeyToAddress(
                        multisigKeyPair.getPublicKey()),
                    List.of(new UnresolvedMosaicDescriptor(
                        new UnresolvedMosaicId(
                            IdGenerator.generateMosaicAliasId(
                                "symbol.xym")),
                        new Amount(1_000_000))), // 1 XYM
                    null),
                multisigKeyPair.getPublicKey());

        // Build the wrapper aggregate transaction
        final List<EmbeddedTransaction> embeddedTransactions =
            List.of(transferTransaction);
        final Transaction transaction =
            facade.createTransactionFromTypedDescriptor(
                new AggregateCompleteTransactionV3Descriptor(
                    SymbolFacade.hashEmbeddedTransactions(
                        embeddedTransactions),
                    embeddedTransactions,
                    null),
                cosignatoryKeyPair.getPublicKey(),
                feeMultiplier,
                2 * 60 * 60);

        // Sign the aggregate using the cosignatory's signature
        final String jsonPayload = SymbolTransactionFactory
            .attachSignature(
                transaction,
                facade.signTransaction(
                    cosignatoryKeyPair, transaction));
        System.out.println("Built transaction:");
        System.out.println(JSON_MAPPER.writerWithDefaultPrettyPrinter()
            .writeValueAsString(transaction.toJson()));

        // Announce the transaction
        final String transactionHash =
            facade.hashTransaction(transaction).toString();
        System.out.printf("Transaction hash: %s%n", transactionHash);
        announceTransaction(jsonPayload, "transaction");

        // Wait for confirmation
        waitForConfirmation(transactionHash, "transaction");
    }
}

Download source

Code Explanation⚓︎

In general, signing a transaction on behalf of a multisig account only requires wrapping it in an aggregate transaction that provides the required cosignatures.

This tutorial builds an embedded transaction containing the transfer, using the multisig account as the signer, since this is the origin of the transfer. A complete aggregate transaction then wraps the transfer transaction, signed by the cosignatory, since this is the account that can authorize the transaction.

Setting Up the Accounts⚓︎

MULTISIG_PRIVATE_KEY = os.getenv(
    'MULTISIG_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000001')
multisig_key_pair = SymbolFacade.KeyPair(
    PrivateKey(MULTISIG_PRIVATE_KEY))
print(f'Multisig public key: {multisig_key_pair.public_key}')
COSIGNATORY0_PRIVATE_KEY = os.getenv(
    'COSIGNATORY0_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000002')
cosignatory_key_pair = SymbolFacade.KeyPair(
    PrivateKey(COSIGNATORY0_PRIVATE_KEY))
print(f'Cosignatory public key: {cosignatory_key_pair.public_key}')
const MULTISIG_PRIVATE_KEY = process.env.MULTISIG_PRIVATE_KEY || (
    '0000000000000000000000000000000000000000000000000000000000000001');
const multisigKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(MULTISIG_PRIVATE_KEY));
console.log(`Multisig public key: ${multisigKeyPair.publicKey}`);
const COSIGNATORY0_PRIVATE_KEY = process.env.COSIGNATORY0_PRIVATE_KEY ||
    '0000000000000000000000000000000000000000000000000000000000000002';
const cosignatoryKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(COSIGNATORY0_PRIVATE_KEY));
console.log(`Cosignatory public key: ${cosignatoryKeyPair.publicKey}`);
        final String multisigPrivateKey = System.getenv().getOrDefault(
            "MULTISIG_PRIVATE_KEY", "%064X".formatted(1));

        final KeyPair multisigKeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(multisigPrivateKey));
        System.out.printf("Multisig public key: %s%n",
            multisigKeyPair.getPublicKey());
        final String cosignatory0PrivateKey = System.getenv().getOrDefault(
            "COSIGNATORY0_PRIVATE_KEY", "%064X".formatted(2));
        final KeyPair cosignatoryKeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(cosignatory0PrivateKey));
        System.out.printf("Cosignatory public key: %s%n",
            cosignatoryKeyPair.getPublicKey());

The tutorial requires two separate accounts. Their private keys can be provided through environment variables. If not set, default values are used:

Environment Variable Default value Purpose
MULTISIG_PRIVATE_KEY 0000..0001 Multisig account
COSIGNATORY0_PRIVATE_KEY 0000..0002 Cosignatory account

Each private key is a 64-character hexadecimal string.

The cosignatory account must hold enough funds to pay the transaction fee. If the default values are used, these accounts may already be funded.

The snippet above derives and stores the key pair of each account for later use.

    # 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.

Building the Transaction⚓︎

    # Build the embedded transfer transaction
    transfer_transaction = (
        facade.create_embedded_transaction_from_descriptor(
            {
                'type': 'transfer_transaction_v1',
                'recipient_address':
                    facade.network.public_key_to_address(
                        multisig_key_pair.public_key),
                'mosaics': [{
                    'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
                    'amount': 1_000_000  # 1 XYM
                }]
            },
            multisig_key_pair.public_key))
    // Build the embedded transfer transaction
    const transferTransaction =
        facade.createEmbeddedTransactionFromTypedDescriptor(
            new descriptors.TransferTransactionV1Descriptor(
                facade.network.publicKeyToAddress(
                    multisigKeyPair.publicKey),
                [
                    new descriptors.UnresolvedMosaicDescriptor(
                        generateMosaicAliasId('symbol.xym'),
                        new models.Amount(1_000_000n)) // 1 XYM
                ],
                undefined),
            multisigKeyPair.publicKey);
        // Build the embedded transfer transaction
        final EmbeddedTransaction transferTransaction =
            facade.createEmbeddedTransactionFromTypedDescriptor(
                new TransferTransactionV1Descriptor(
                    facade.network.publicKeyToAddress(
                        multisigKeyPair.getPublicKey()),
                    List.of(new UnresolvedMosaicDescriptor(
                        new UnresolvedMosaicId(
                            IdGenerator.generateMosaicAliasId(
                                "symbol.xym")),
                        new Amount(1_000_000))), // 1 XYM
                    null),
                multisigKeyPair.getPublicKey());

The embedded transfer transaction is created from the transaction's descriptor and the signer public key. The signer public key belongs to the account whose funds are being transferred, that is, the multisignature account. The descriptor includes:

  • : in this particular example, the funds are sent back to the sender, so the recipient is also the multisig account.

  • mosaics: 1'000'000 atomic units of the symbol.xym mosaic, corresponding to 1 XYM, as explained in the Transfer Transaction tutorial.

The embedded transaction is then wrapped in an aggregate transaction, even though it is the only inner transaction:

    # Build the wrapper aggregate transaction
    transaction = facade.create_transaction_from_descriptor(
        {
            'type': 'aggregate_complete_transaction_v3',
            'transactions_hash': facade.hash_embedded_transactions(
                [transfer_transaction]),
            'transactions': [transfer_transaction]
        },
        cosignatory_key_pair.public_key,
        fee_multiplier,
        2 * 60 * 60)
    // Build the wrapper aggregate transaction
    const transaction = facade.createTransactionFromTypedDescriptor(
        new descriptors.AggregateCompleteTransactionV3Descriptor(
            facade.static.hashEmbeddedTransactions([transferTransaction]),
            [transferTransaction],
            undefined),
        cosignatoryKeyPair.publicKey,
        feeMultiplier,
        2 * 60 * 60);
        // Build the wrapper aggregate transaction
        final List<EmbeddedTransaction> embeddedTransactions =
            List.of(transferTransaction);
        final Transaction transaction =
            facade.createTransactionFromTypedDescriptor(
                new AggregateCompleteTransactionV3Descriptor(
                    SymbolFacade.hashEmbeddedTransactions(
                        embeddedTransactions),
                    embeddedTransactions,
                    null),
                cosignatoryKeyPair.getPublicKey(),
                feeMultiplier,
                2 * 60 * 60);

Its descriptor contains the embedded transactions. The aggregate is then created with the cosignatory's public key as signer, because the cosignatory authorizes the transaction and pays its fees.

  • transactions: the list of embedded transactions. This example has only one, but there could be any number of them.

For simplicity, the tutorial uses a complete aggregate transaction. See the tutorials on complete and bonded aggregate transactions for more details.

Finally, the aggregate transaction is signed by the cosignatory:

    # Sign the aggregate transaction using the cosignatory's signature
    json_payload = facade.transaction_factory.attach_signature(
        transaction,
        facade.sign_transaction(cosignatory_key_pair, transaction))
    print('Built transaction:')
    print(json.dumps(transaction.to_json(), indent=2))
    // Sign the aggregate transaction using the cosignatory's signature
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction,
        facade.signTransaction(cosignatoryKeyPair, transaction));
    console.log('Built transaction:');
    console.dir(transaction.toJson(), { colors: true });
        // Sign the aggregate using the cosignatory's signature
        final String jsonPayload = SymbolTransactionFactory
            .attachSignature(
                transaction,
                facade.signTransaction(
                    cosignatoryKeyPair, transaction));
        System.out.println("Built transaction:");
        System.out.println(JSON_MAPPER.writerWithDefaultPrettyPrinter()
            .writeValueAsString(transaction.toJson()));

Multiple cosignatories

In other multisig configurations, more signatures might be required. In that case, they are attached using instead of .

See the Configuring a Multisignature Account tutorial for an example.

Submitting the Aggregate Transaction⚓︎

The final step is to announce the transaction and wait for its confirmation, as described in the Transfer transaction tutorial.

    # Announce the transaction
    transaction_hash = facade.hash_transaction(transaction)
    print(f'Transaction hash: {transaction_hash}')
    announce_transaction(json_payload, 'transaction')

    # Wait for confirmation
    wait_for_confirmation(transaction_hash, 'transaction')
    // Announce the transaction
    const transactionHash =
        facade.hashTransaction(transaction).toString();
    console.log('Transaction hash:', transactionHash);
    await announceTransaction(jsonPayload, 'transaction');

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

        // Wait for confirmation
        waitForConfirmation(transactionHash, "transaction");

Transactions are rejected if they violate protocol constraints. The following table summarizes the most common error sources:

Error message Probable cause
Multisig Operation Prohibited By Account The multisig account tried to sign the aggregate transaction itself.
Aggregate Ineligible Cosignatories The signer is not in the cosignatories list.
Consumer Batch Signature Not Verifiable The signature attached to the aggregate transaction does not match its

Output⚓︎

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

Using node https://reference.symboltest.net:3001
Multisig public key: 4CB5ABF6AD79FBF5ABBCCAFCC269D85CD2651ED4B885B5869F241AEDF0A5BA29
Cosignatory public key: 7422B9887598068E32C4448A949ADB290D0F4E35B9E01B0EE5F1A1E600FE2674
Fetching recommended fees from /network/fees/transaction
  Fee multiplier: 100
Built transaction:
{
  "signature": "A533F9B281174AE537944B25752DB75FFC277CBC3958347E741E53CA4A1D02EBA9C4B6F993DF05A250F07C270E2CF6C21DA6344FE31AE701390BA5AD7BC62F0C",
  "signer_public_key": "7422B9887598068E32C4448A949ADB290D0F4E35B9E01B0EE5F1A1E600FE2674",
  "version": 3,
  "network": 152,
  "type": 16705,
  "fee": "26400",
  "deadline": "102879850165",
  "transactions_hash": "8E64E490EB8B7887CAE6BE6846F67ADEEAE0AF3525CF98D84044ADE9F6BA488F",
  "transactions": [
    {
      "signer_public_key": "4CB5ABF6AD79FBF5ABBCCAFCC269D85CD2651ED4B885B5869F241AEDF0A5BA29",
      "version": 1,
      "network": 152,
      "type": 16724,
      "recipient_address": "987D075454716222F609929E883174AD8C996D5828C938BC",
      "mosaics": [
        {
          "mosaic_id": "16666583871264174062",
          "amount": "1000000"
        }
      ],
      "message": ""
    }
  ],
  "cosignatures": []
}
Transaction hash: AB9FB75C150AD471AC73A6CF278D82D122E2583CF02B3B9067274608A7D334E4
Announcing transaction to /transactions
  Response: {"message":"packet 9 was pushed to the network via /transactions"}
Waiting for transaction confirmation...
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  ...
  Transaction status: confirmed
transaction confirmed in 9 seconds

Key points in the output:

  • Lines 2-3: Public keys of all involved accounts.
  • Line 9 (signer_public_key): Signer of the aggregate transaction. Note that it matches the cosignatory account.
  • Line 18 (signer_public_key): Signer of the embedded transfer transaction. Note that it matches the multisig account.
  • Line 22 (recipient_address): Encoded address of the multisig account.

The transaction hashes shown in the output can be used to look up the transactions in the Symbol Testnet Explorer.

Conclusion⚓︎

This tutorial is functionally identical to the Transfer Transaction tutorial, but using a multisignature account as the source account.

In particular, the tutorial showed how to:

Step Related documentation
Wrap transfer in an embedded transaction , TransferTransactionV1
Attach signatures in the right place