Skip to content

Configuring a Multisignature Account⚓︎

A multisignature account, also called multisig, cannot initiate transactions on its own. Instead, it relies on cosignatory accounts to create transactions and sign them on its behalf.

This tutorial shows how to convert a regular account into a multisig account that requires approval from one of two cosignatories. If the account is already multisig, the tutorial instead demonstrates how to remove the cosignatories and revert the account to a regular account.

The multisignature structure used in this tutorial is shown below:

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

Multilevel multisignature accounts

More complex configurations, where a cosignatory is itself a multisig account, are also supported, up to three levels deep.

Multisig accounts can be configured in any order.
However, once an account is converted into a multisig, it can no longer sign its own transactions and must rely exclusively on the cosignatories configured at that point.

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

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.Network import NetworkTimestamp

NODE_URL = os.environ.get(
    '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 response:
        print(f'  Response: {response.read().decode()}')

# Helper function to wait for transaction confirmation
def wait_for_confirmation(transaction_hash, label):
    print(f'Waiting for {label} confirmation...')
    for attempt in range(60):
        time.sleep(1)
        try:
            url = f'{NODE_URL}/transactionStatus/{transaction_hash}'
            with urllib.request.urlopen(url) as response:
                status = json.loads(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 Exception(f'{label} failed: {status["code"]}')
        except urllib.error.HTTPError:
            print('  Transaction status: unknown')
    raise Exception(f'{label} not confirmed after 60 seconds')

# Returns the cosignatory addresses of the provided multisig account,
# or an empty list if the account is not multisig or has never been used
def get_multisig_cosignatories(address):
    multisig_path = f'/account/{address}/multisig'
    print(f'Getting cosignatories from {multisig_path}')
    try:
        url = f'{NODE_URL}{multisig_path}'
        with urllib.request.urlopen(url) as response:
            status = json.loads(response.read().decode())
            cosignatories = status['multisig']['cosignatoryAddresses']
            print(f'  Response: {cosignatories}')
            return cosignatories
    except urllib.error.HTTPError:
        # The address has never been used
        print('  Response: No cosignatories')
    return []

# Returns a transaction that turns a regular account into a multisig
def multisig_enable_transaction():
    # Create an embedded multisig account modification transaction
    # that adds two cosignatories
    embedded_transaction = facade.transaction_factory.create_embedded({
        'type': 'multisig_account_modification_transaction_v1',
        # This is the account that will be turned into a multisig
        'signer_public_key': multisig_key_pair.public_key,
        # Increment of the number of signatures required for approvals
        'min_approval_delta': 1,
        # Increment of the number of signatures required for removals
        'min_removal_delta': 1,
        'address_additions': cosignatory_addresses
    })

    # Build the aggregate transaction
    embedded_transactions = [embedded_transaction]
    transaction = facade.transaction_factory.create({
        'type': 'aggregate_complete_transaction_v3',
        # This is the account that will pay for this transaction
        'signer_public_key': multisig_key_pair.public_key,
        'deadline': timestamp.add_hours(2).timestamp,
        'transactions_hash': facade.hash_embedded_transactions(
            embedded_transactions),
        'transactions': embedded_transactions
    })
    # Reserve space for two cosignatures (each is 104 bytes)
    # and calculate fee for the final transaction size
    transaction.fee = Amount(
        fee_mult * (transaction.size + 104 * len(cosignatory_key_pairs)))
    print('Enabling the multisig with the aggregate transaction:')
    print(json.dumps(transaction.to_json(), indent=2))

    # Sign the aggregate transaction with the multisig's signature
    facade.transaction_factory.attach_signature(transaction,
        facade.sign_transaction(multisig_key_pair, transaction))

    # Append signatures from all cosignatories
    for cosignatory_key_pair in cosignatory_key_pairs:
        transaction.cosignatures.append(
            facade.cosign_transaction(cosignatory_key_pair, transaction)
        )

    return transaction

# Returns a transaction that turns a multisig into a regular account
def multisig_disable_transaction():
    # Create two embedded multisig account modification transactions
    # because cosignatories must be removed one by one
    embedded_transaction_1 = facade.transaction_factory.create_embedded({
        'type': 'multisig_account_modification_transaction_v1',
        # This is the multisig account that will be modified
        'signer_public_key': multisig_key_pair.public_key,
        # Keep required signatures unchanged for this step
        'min_approval_delta': 0,
        'min_removal_delta': 0,
        'address_deletions': [cosignatory_addresses[1]]
    })
    embedded_transaction_2 = facade.transaction_factory.create_embedded({
        'type': 'multisig_account_modification_transaction_v1',
        # This is the multisig account that will be modified
        'signer_public_key': multisig_key_pair.public_key,
        # Decrease required signatures after final removal
        'min_approval_delta': -1,
        'min_removal_delta': -1,
        'address_deletions': [cosignatory_addresses[0]]
    })

    # Build the aggregate transaction
    embedded_transactions = [embedded_transaction_1,
        embedded_transaction_2]
    transaction = facade.transaction_factory.create({
        'type': 'aggregate_complete_transaction_v3',
        # This is the account that will pay for all transactions
        'signer_public_key': cosignatory_key_pairs[0].public_key,
        'deadline': timestamp.add_hours(2).timestamp,
        'transactions_hash': facade.hash_embedded_transactions(
            embedded_transactions),
        'transactions': embedded_transactions
    })
    # Calculate fee for the final transaction size
    # (No need to reserve space for cosignatures, as there are none)
    transaction.fee = Amount(fee_mult * transaction.size)
    print('Disabling the multisig with the aggregate transaction:')
    print(json.dumps(transaction.to_json(), indent=2))

    # Sign the aggregate transaction using the first cosigner's signature
    facade.transaction_factory.attach_signature(transaction,
        facade.sign_transaction(cosignatory_key_pairs[0], transaction))

    return transaction

facade = SymbolFacade('testnet')

KEY_TEMPLATE = '0' * 63 + '{}'

# Setup the keys for the multisig account and its two cosignatories
MULTISIG_PRIVATE_KEY = os.getenv(
    'MULTISIG_PRIVATE_KEY', KEY_TEMPLATE.format(1))
multisig_key_pair = SymbolFacade.KeyPair(PrivateKey(MULTISIG_PRIVATE_KEY))
multisig_address = facade.network.public_key_to_address(
    multisig_key_pair.public_key)
print(f'Multisig address: {multisig_address} '
    f'(public key {multisig_key_pair.public_key})')

cosignatory_key_pairs = []
cosignatory_addresses = []
for i in range(2):
    COSIGNATORY_PRIVATE_KEY = os.getenv(
        f'COSIGNATORY{i}_PRIVATE_KEY', KEY_TEMPLATE.format(i + 2))
    kp = SymbolFacade.KeyPair(PrivateKey(COSIGNATORY_PRIVATE_KEY))
    cosignatory_key_pairs.append(kp)
    addr = facade.network.public_key_to_address(kp.public_key)
    cosignatory_addresses.append(addr)
    print(f'Cosignatory {i} address: {addr} (public key {kp.public_key})')

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

    # Get current state of the multisig account and decide which
    # operation to perform
    cosignatories = get_multisig_cosignatories(multisig_address)
    if len(cosignatories) == 0:
        # Enable the multisig
        transaction = multisig_enable_transaction()
        # This operation must be signed by the multisig account
        signer_key_pair = multisig_key_pair
    else:
        # Disable the multisig
        transaction = multisig_disable_transaction()
        # This operation must be signed by one of the cosigners
        signer_key_pair = cosignatory_key_pairs[0]
    json_payload = facade.transaction_factory.attach_signature(
        transaction,
        facade.sign_transaction(signer_key_pair, transaction))

    # Announce and wait for confirmation
    transaction_hash = facade.hash_transaction(transaction)
    print(f'Built aggregate transaction with hash: {transaction_hash}')
    announce_transaction(json_payload, 'aggregate transaction')
    wait_for_confirmation(transaction_hash, 'aggregate transaction')

except Exception as e:
    print(e)

Download source

import { PrivateKey } from 'symbol-sdk';
import {
    KeyPair,
    SymbolTransactionFactory,
    models,
    NetworkTimestamp,
    SymbolFacade
} 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; attempt < 60; attempt++) {
        await new Promise(resolve => setTimeout(resolve, 1000));
        try {
            const response = await fetch(
                `${NODE_URL}/transactionStatus/${transactionHash}`);
            const status = await response.json();
            console.log('  Transaction status:', status.group);
            if (status.group === 'confirmed') {
                console.log(`${label} confirmed in`, attempt, 'seconds');
                return;
            }
            if (status.group === 'failed') {
                throw new Error(`${label} failed: ${status.code}`);
            }
        } catch (e) {
            if (e.message.includes('failed'))
                throw e;
            console.log('  Transaction status: unknown');
        }
    }
    throw new Error(`${label} not confirmed after 60 seconds`);
}

// Returns the cosignatory addresses of the provided multisig account,
// or an empty list if the account is not multisig or has never been used
async function getMultisigCosignatories(address) {
    const multisigPath = `/account/${address}/multisig`;
    console.log(`Getting cosignatories from ${multisigPath}`);
    try {
        const response = await fetch(`${NODE_URL}${multisigPath}`);
        const json = await response.json();
        const cosignatories = json.multisig.cosignatoryAddresses;
        console.log('  Response:', JSON.stringify(cosignatories));
        return cosignatories;
    } catch {
        console.log('  Response: No cosignatories');
        return [];
    }
}

// Returns a transaction that turns a regular account into a multisig
function multisigEnableTransaction(timestamp, feeMult) {
    // Create an embedded multisig account modification transaction
    // that adds two cosignatories
    const embeddedTransaction = facade.transactionFactory
        .createEmbedded({
            type: 'multisig_account_modification_transaction_v1',
            // This is the account that will be turned into a multisig
            signerPublicKey: multisigKeyPair.publicKey,
            // Delta of the number of signatures required for approvals
            minApprovalDelta: 1,
            // Delta of the number of signatures required for removals
            minRemovalDelta: 1,
            addressAdditions: cosignatoryAddresses
        });

    // Build the aggregate transaction
    const embeddedTransactions = [embeddedTransaction];
    const transaction = facade.transactionFactory.create({
        type: 'aggregate_complete_transaction_v3',
        // This is the account that will pay for this transaction
        signerPublicKey: multisigKeyPair.publicKey,
        deadline: timestamp.addHours(2).timestamp,
        transactionsHash: facade.static.hashEmbeddedTransactions(
            embeddedTransactions),
        transactions: embeddedTransactions
    });
    // Reserve space for two cosignatures (each is 104 bytes)
    // and calculate fee for the final transaction size
    transaction.fee = new models.Amount(
        feeMult * (transaction.size + 104 * cosignatoryKeyPairs.length));
    console.log('Enabling the multisig with the aggregate transaction:');
    console.log(JSON.stringify(transaction.toJson(), null, 2));

    // Sign the aggregate transaction with the multisig's signature
    SymbolTransactionFactory.attachSignature(transaction,
        facade.signTransaction(multisigKeyPair, transaction));

    // Append signatures from all cosignatories
    for (const cosignatoryKeyPair of cosignatoryKeyPairs) {
        transaction.cosignatures.push(
            facade.cosignTransaction(cosignatoryKeyPair, transaction));
    }

    return transaction;
}

// Returns a transaction that turns a multisig into a regular account
function multisigDisableTransaction(timestamp, feeMult) {
    // Create two embedded multisig account modification transactions
    // because cosignatories must be removed one by one
    const embeddedTransaction1 = facade.transactionFactory
        .createEmbedded({
            type: 'multisig_account_modification_transaction_v1',
            // This is the multisig account that will be modified
            signerPublicKey: multisigKeyPair.publicKey,
            // Keep required signatures unchanged for this step
            minApprovalDelta: 0,
            minRemovalDelta: 0,
            addressDeletions: [cosignatoryAddresses[1]]
        });
    const embeddedTransaction2 = facade.transactionFactory
        .createEmbedded({
            type: 'multisig_account_modification_transaction_v1',
            // This is the multisig account that will be modified
            signerPublicKey: multisigKeyPair.publicKey,
            // Decrease required signatures after final removal
            minApprovalDelta: -1,
            minRemovalDelta: -1,
            addressDeletions: [cosignatoryAddresses[0]]
        });

    // Build the aggregate transaction
    const embeddedTransactions = [embeddedTransaction1,
        embeddedTransaction2];
    const transaction = facade.transactionFactory.create({
        type: 'aggregate_complete_transaction_v3',
        // This is the account that will pay for this transaction
        signerPublicKey: cosignatoryKeyPairs[0].publicKey,
        deadline: timestamp.addHours(2).timestamp,
        transactionsHash: facade.static.hashEmbeddedTransactions(
            embeddedTransactions),
        transactions: embeddedTransactions
    });
    // Calculate fee for the final transaction size
    // (No need to reserve space for cosignatures, as there are none)
    transaction.fee = new models.Amount(feeMult * transaction.size);
    console.log('Disabling the multisig with the aggregate transaction:');
    console.log(JSON.stringify(transaction.toJson(), null, 2));

    // Sign the aggregate transaction using the first cosigner's signature
    SymbolTransactionFactory.attachSignature(transaction,
        facade.signTransaction(cosignatoryKeyPairs[0], transaction));

    return transaction;
}

const facade = new SymbolFacade('testnet');

const KEY_PREFIX = '0'.repeat(63);

// Setup the keys for the multisig account and its two cosignatories
const MULTISIG_PRIVATE_KEY = process.env.MULTISIG_PRIVATE_KEY || (
    KEY_PREFIX + '1');
const multisigKeyPair = new KeyPair(new PrivateKey(MULTISIG_PRIVATE_KEY));
const multisigAddress = facade.network.publicKeyToAddress(
    multisigKeyPair.publicKey);
console.log(`Multisig address: ${multisigAddress}`,
    `(public key ${multisigKeyPair.publicKey})`);

const cosignatoryKeyPairs = [];
const cosignatoryAddresses = [];
for (let i = 0; i < 2; i++) {
    const COSIGNATORY_PRIVATE_KEY =
        process.env[`COSIGNATORY${i}_PRIVATE_KEY`] || (
            KEY_PREFIX + String(i + 2));
    const kp = new KeyPair(new PrivateKey(COSIGNATORY_PRIVATE_KEY));
    cosignatoryKeyPairs.push(kp);
    const addr = facade.network.publicKeyToAddress(kp.publicKey);
    cosignatoryAddresses.push(addr);
    console.log(`Cosignatory ${i} address: ${addr}`,
        `(public key ${kp.publicKey})`);
}

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

    // Get current state of the multisig account and decide which
    // operation to perform
    const cosignatories = await getMultisigCosignatories(multisigAddress);
    let transaction;
    let signerKeyPair;
    if (cosignatories.length === 0) {
        // Enable the multisig
        transaction = multisigEnableTransaction(timestamp, feeMult);
        // This operation must be signed by the multisig account
        signerKeyPair = multisigKeyPair;
    } else {
        // Disable the multisig
        transaction = multisigDisableTransaction(timestamp, feeMult);
        // This operation must be signed by one of the cosigners
        signerKeyPair = cosignatoryKeyPairs[0];
    }
    const payload = SymbolTransactionFactory.attachSignature(
        transaction,
        facade.signTransaction(signerKeyPair, transaction));

    // Announce and wait for confirmation
    const transactionHash =
        facade.hashTransaction(transaction).toString();
    console.log(
        'Built aggregate transaction with hash:', transactionHash);
    await announceTransaction(payload, 'aggregate transaction');
    await waitForConfirmation(transactionHash, 'aggregate transaction');

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

Download source

Code Explanation⚓︎

The code begins by defining two helper functions. For details on how transactions are announced and how their confirmation is tracked, see the Transfer transaction tutorial. The remaining helper functions are described in the sections below.

The tutorial then proceeds to set up the required keys, fetch the current network conditions, and detect the current configuration of the multisig account.

Depending on whether the account is already configured as a multisig, a transaction is created to enable or disable it as appropriate. Finally, the transaction is announced and confirmed.

Setting Up the Accounts⚓︎

KEY_TEMPLATE = '0' * 63 + '{}'

# Setup the keys for the multisig account and its two cosignatories
MULTISIG_PRIVATE_KEY = os.getenv(
    'MULTISIG_PRIVATE_KEY', KEY_TEMPLATE.format(1))
multisig_key_pair = SymbolFacade.KeyPair(PrivateKey(MULTISIG_PRIVATE_KEY))
multisig_address = facade.network.public_key_to_address(
    multisig_key_pair.public_key)
print(f'Multisig address: {multisig_address} '
    f'(public key {multisig_key_pair.public_key})')

cosignatory_key_pairs = []
cosignatory_addresses = []
for i in range(2):
    COSIGNATORY_PRIVATE_KEY = os.getenv(
        f'COSIGNATORY{i}_PRIVATE_KEY', KEY_TEMPLATE.format(i + 2))
    kp = SymbolFacade.KeyPair(PrivateKey(COSIGNATORY_PRIVATE_KEY))
    cosignatory_key_pairs.append(kp)
    addr = facade.network.public_key_to_address(kp.public_key)
    cosignatory_addresses.append(addr)
    print(f'Cosignatory {i} address: {addr} (public key {kp.public_key})')
const KEY_PREFIX = '0'.repeat(63);

// Setup the keys for the multisig account and its two cosignatories
const MULTISIG_PRIVATE_KEY = process.env.MULTISIG_PRIVATE_KEY || (
    KEY_PREFIX + '1');
const multisigKeyPair = new KeyPair(new PrivateKey(MULTISIG_PRIVATE_KEY));
const multisigAddress = facade.network.publicKeyToAddress(
    multisigKeyPair.publicKey);
console.log(`Multisig address: ${multisigAddress}`,
    `(public key ${multisigKeyPair.publicKey})`);

const cosignatoryKeyPairs = [];
const cosignatoryAddresses = [];
for (let i = 0; i < 2; i++) {
    const COSIGNATORY_PRIVATE_KEY =
        process.env[`COSIGNATORY${i}_PRIVATE_KEY`] || (
            KEY_PREFIX + String(i + 2));
    const kp = new KeyPair(new PrivateKey(COSIGNATORY_PRIVATE_KEY));
    cosignatoryKeyPairs.push(kp);
    const addr = facade.network.publicKeyToAddress(kp.publicKey);
    cosignatoryAddresses.push(addr);
    console.log(`Cosignatory ${i} address: ${addr}`,
        `(public key ${kp.publicKey})`);
}

The tutorial requires three 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 First cosignatory account
COSIGNATORY1_PRIVATE_KEY 0000..0003 Second cosignatory account

Each private key is a 64-character hexadecimal string.

The multisig account and its first cosignatory must hold enough funds to announce transactions. If the default values are used, these accounts may already be funded.

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

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

Network time and recommended fees are fetched from /node/time GET and /network/fees/transaction GET respectively, following the process described in the Transfer Transaction tutorial.

Detecting the Multisig⚓︎

The following function retrieves the list of current cosignatories for a given address using the /account/{address}/multisig GET endpoint. If the account is not configured as a multisig, or has never been used, the function returns an empty list.

# Returns the cosignatory addresses of the provided multisig account,
# or an empty list if the account is not multisig or has never been used
def get_multisig_cosignatories(address):
    multisig_path = f'/account/{address}/multisig'
    print(f'Getting cosignatories from {multisig_path}')
    try:
        url = f'{NODE_URL}{multisig_path}'
        with urllib.request.urlopen(url) as response:
            status = json.loads(response.read().decode())
            cosignatories = status['multisig']['cosignatoryAddresses']
            print(f'  Response: {cosignatories}')
            return cosignatories
    except urllib.error.HTTPError:
        # The address has never been used
        print('  Response: No cosignatories')
    return []
// Returns the cosignatory addresses of the provided multisig account,
// or an empty list if the account is not multisig or has never been used
async function getMultisigCosignatories(address) {
    const multisigPath = `/account/${address}/multisig`;
    console.log(`Getting cosignatories from ${multisigPath}`);
    try {
        const response = await fetch(`${NODE_URL}${multisigPath}`);
        const json = await response.json();
        const cosignatories = json.multisig.cosignatoryAddresses;
        console.log('  Response:', JSON.stringify(cosignatories));
        return cosignatories;
    } catch {
        console.log('  Response: No cosignatories');
        return [];
    }
}

This list is then used to determine the tutorial's mode of operation, build the appropriate configuration transaction, and sign it.

    # Get current state of the multisig account and decide which
    # operation to perform
    cosignatories = get_multisig_cosignatories(multisig_address)
    if len(cosignatories) == 0:
        # Enable the multisig
        transaction = multisig_enable_transaction()
        # This operation must be signed by the multisig account
        signer_key_pair = multisig_key_pair
    else:
        # Disable the multisig
        transaction = multisig_disable_transaction()
        # This operation must be signed by one of the cosigners
        signer_key_pair = cosignatory_key_pairs[0]
    json_payload = facade.transaction_factory.attach_signature(
        transaction,
        facade.sign_transaction(signer_key_pair, transaction))
    // Get current state of the multisig account and decide which
    // operation to perform
    const cosignatories = await getMultisigCosignatories(multisigAddress);
    let transaction;
    let signerKeyPair;
    if (cosignatories.length === 0) {
        // Enable the multisig
        transaction = multisigEnableTransaction(timestamp, feeMult);
        // This operation must be signed by the multisig account
        signerKeyPair = multisigKeyPair;
    } else {
        // Disable the multisig
        transaction = multisigDisableTransaction(timestamp, feeMult);
        // This operation must be signed by one of the cosigners
        signerKeyPair = cosignatoryKeyPairs[0];
    }
    const payload = SymbolTransactionFactory.attachSignature(
        transaction,
        facade.signTransaction(signerKeyPair, transaction));

The only differences between enabling and disabling the multisig are the transaction that is created and the account that signs it, as shown in the next two sections.

Enabling the Multisig⚓︎

All changes to the multisig configuration of an account, including adding or removing cosignatories, are performed using a MultisigAccountModificationTransaction, which must be embedded in an aggregate transaction:

    # Create an embedded multisig account modification transaction
    # that adds two cosignatories
    embedded_transaction = facade.transaction_factory.create_embedded({
        'type': 'multisig_account_modification_transaction_v1',
        # This is the account that will be turned into a multisig
        'signer_public_key': multisig_key_pair.public_key,
        # Increment of the number of signatures required for approvals
        'min_approval_delta': 1,
        # Increment of the number of signatures required for removals
        'min_removal_delta': 1,
        'address_additions': cosignatory_addresses
    })
    // Create an embedded multisig account modification transaction
    // that adds two cosignatories
    const embeddedTransaction = facade.transactionFactory
        .createEmbedded({
            type: 'multisig_account_modification_transaction_v1',
            // This is the account that will be turned into a multisig
            signerPublicKey: multisigKeyPair.publicKey,
            // Delta of the number of signatures required for approvals
            minApprovalDelta: 1,
            // Delta of the number of signatures required for removals
            minRemovalDelta: 1,
            addressAdditions: cosignatoryAddresses
        });

The embedded MultisigAccountModificationTransaction includes the following fields:

  • signer_public_key: public key of the account whose multisig configuration will be modified.

  • min_approval_delta: difference between the desired value and the current value of the number of signatures that will be required to approve a transaction from the multisig account.

    In this case the account is initially a regular account, so the current required number of signatures is 0. To convert it into a multisig account that requires one signature from one of its cosignatories, the delta is set to 1.

    The delta value can be negative to reduce the current value, as shown in the next section.

  • min_removal_delta: Difference in the number of signatures required to remove cosignatories from the account configuration. This allows, for example, requiring more signatures to remove a cosignatory than to approve a regular transaction, which is often a more sensitive governance operation.

  • address_additions: list of addresses of the cosignatories that will be added to the account. The cosignatory_addresses variable was prepared during the setup phase.

Safety measures

The protocol includes safety mechanisms that help prevent locking an account into an invalid state. Transactions that would result in an invalid multisig configuration are rejected with an error. For example, when:

  • The number of cosignatories is lower than the minimum number of required signatures
  • An address that is not a consignatory is removed
  • Required signatures are missing
  • Unnecessary signatures are included

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

    # Build the aggregate transaction
    embedded_transactions = [embedded_transaction]
    transaction = facade.transaction_factory.create({
        'type': 'aggregate_complete_transaction_v3',
        # This is the account that will pay for this transaction
        'signer_public_key': multisig_key_pair.public_key,
        'deadline': timestamp.add_hours(2).timestamp,
        'transactions_hash': facade.hash_embedded_transactions(
            embedded_transactions),
        'transactions': embedded_transactions
    })
    # Reserve space for two cosignatures (each is 104 bytes)
    # and calculate fee for the final transaction size
    transaction.fee = Amount(
        fee_mult * (transaction.size + 104 * len(cosignatory_key_pairs)))
    print('Enabling the multisig with the aggregate transaction:')
    print(json.dumps(transaction.to_json(), indent=2))
    // Build the aggregate transaction
    const embeddedTransactions = [embeddedTransaction];
    const transaction = facade.transactionFactory.create({
        type: 'aggregate_complete_transaction_v3',
        // This is the account that will pay for this transaction
        signerPublicKey: multisigKeyPair.publicKey,
        deadline: timestamp.addHours(2).timestamp,
        transactionsHash: facade.static.hashEmbeddedTransactions(
            embeddedTransactions),
        transactions: embeddedTransactions
    });
    // Reserve space for two cosignatures (each is 104 bytes)
    // and calculate fee for the final transaction size
    transaction.fee = new models.Amount(
        feeMult * (transaction.size + 104 * cosignatoryKeyPairs.length));
    console.log('Enabling the multisig with the aggregate transaction:');
    console.log(JSON.stringify(transaction.toJson(), null, 2));

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

Care is taken when calculating the transaction fee to account for the space required by all cosignatures.

Finally, signatures are attached to the aggregate transaction:

    # Sign the aggregate transaction with the multisig's signature
    facade.transaction_factory.attach_signature(transaction,
        facade.sign_transaction(multisig_key_pair, transaction))

    # Append signatures from all cosignatories
    for cosignatory_key_pair in cosignatory_key_pairs:
        transaction.cosignatures.append(
            facade.cosign_transaction(cosignatory_key_pair, transaction)
        )
    // Sign the aggregate transaction with the multisig's signature
    SymbolTransactionFactory.attachSignature(transaction,
        facade.signTransaction(multisigKeyPair, transaction));

    // Append signatures from all cosignatories
    for (const cosignatoryKeyPair of cosignatoryKeyPairs) {
        transaction.cosignatures.push(
            facade.cosignTransaction(cosignatoryKeyPair, transaction));
    }

In this case, the signature of the account being converted into a multisig is required, along with the signatures of the cosignatories, which explicitly acknowledge their new responsibility.

One of the signatures is the main signer of the transaction and is added using . The remaining signatures are cosignatures and are added using . The choice of the main signer only affects which account pays the transaction fee.

Once an account has multisig enabled, its own signature is no longer required. Any transaction involving that account instead requires signatures from its cosignatories.

Disabling the Multisig⚓︎

Disabling a multisig configuration requires removing all cosignatories. The process is similar to enabling it, with ttwo key differences: cosignatories must be removed one by one, and the multisig account itself cannot sign the transaction.

For this reason, two MultisigAccountModificationTransactions are created:

    # Create two embedded multisig account modification transactions
    # because cosignatories must be removed one by one
    embedded_transaction_1 = facade.transaction_factory.create_embedded({
        'type': 'multisig_account_modification_transaction_v1',
        # This is the multisig account that will be modified
        'signer_public_key': multisig_key_pair.public_key,
        # Keep required signatures unchanged for this step
        'min_approval_delta': 0,
        'min_removal_delta': 0,
        'address_deletions': [cosignatory_addresses[1]]
    })
    embedded_transaction_2 = facade.transaction_factory.create_embedded({
        'type': 'multisig_account_modification_transaction_v1',
        # This is the multisig account that will be modified
        'signer_public_key': multisig_key_pair.public_key,
        # Decrease required signatures after final removal
        'min_approval_delta': -1,
        'min_removal_delta': -1,
        'address_deletions': [cosignatory_addresses[0]]
    })
    // Create two embedded multisig account modification transactions
    // because cosignatories must be removed one by one
    const embeddedTransaction1 = facade.transactionFactory
        .createEmbedded({
            type: 'multisig_account_modification_transaction_v1',
            // This is the multisig account that will be modified
            signerPublicKey: multisigKeyPair.publicKey,
            // Keep required signatures unchanged for this step
            minApprovalDelta: 0,
            minRemovalDelta: 0,
            addressDeletions: [cosignatoryAddresses[1]]
        });
    const embeddedTransaction2 = facade.transactionFactory
        .createEmbedded({
            type: 'multisig_account_modification_transaction_v1',
            // This is the multisig account that will be modified
            signerPublicKey: multisigKeyPair.publicKey,
            // Decrease required signatures after final removal
            minApprovalDelta: -1,
            minRemovalDelta: -1,
            addressDeletions: [cosignatoryAddresses[0]]
        });

In both transactions, signer_public_key is set to the multisig account's public key.

The first transaction removes cosignatory_addresses[1] without modifying the approval or removal deltas, because one cosignatory still remains and signatures are still required.

The second transaction removes the last remaining cosignatory and sets both min_approval_delta and min_removal_delta to -1. At this point, the current value of both fields is 1, as configured during the enable step, and the desired value is 0, so the delta is -1.

Both embedded transactions are then wrapped in an aggregate transaction and signed:

    # Build the aggregate transaction
    embedded_transactions = [embedded_transaction_1,
        embedded_transaction_2]
    transaction = facade.transaction_factory.create({
        'type': 'aggregate_complete_transaction_v3',
        # This is the account that will pay for all transactions
        'signer_public_key': cosignatory_key_pairs[0].public_key,
        'deadline': timestamp.add_hours(2).timestamp,
        'transactions_hash': facade.hash_embedded_transactions(
            embedded_transactions),
        'transactions': embedded_transactions
    })
    # Calculate fee for the final transaction size
    # (No need to reserve space for cosignatures, as there are none)
    transaction.fee = Amount(fee_mult * transaction.size)
    print('Disabling the multisig with the aggregate transaction:')
    print(json.dumps(transaction.to_json(), indent=2))

    # Sign the aggregate transaction using the first cosigner's signature
    facade.transaction_factory.attach_signature(transaction,
        facade.sign_transaction(cosignatory_key_pairs[0], transaction))
    // Build the aggregate transaction
    const embeddedTransactions = [embeddedTransaction1,
        embeddedTransaction2];
    const transaction = facade.transactionFactory.create({
        type: 'aggregate_complete_transaction_v3',
        // This is the account that will pay for this transaction
        signerPublicKey: cosignatoryKeyPairs[0].publicKey,
        deadline: timestamp.addHours(2).timestamp,
        transactionsHash: facade.static.hashEmbeddedTransactions(
            embeddedTransactions),
        transactions: embeddedTransactions
    });
    // Calculate fee for the final transaction size
    // (No need to reserve space for cosignatures, as there are none)
    transaction.fee = new models.Amount(feeMult * transaction.size);
    console.log('Disabling the multisig with the aggregate transaction:');
    console.log(JSON.stringify(transaction.toJson(), null, 2));

    // Sign the aggregate transaction using the first cosigner's signature
    SymbolTransactionFactory.attachSignature(transaction,
        facade.signTransaction(cosignatoryKeyPairs[0], transaction));

The aggregate transaction is signed by cosignatory_addresses[0]. This is the only valid option: once an account has cosignatories, it can no longer sign transactions on its own, and cosignatory_addresses[1] is removed from the multisig after the first embedded transaction is executed.

As a result, no cosignatures are required. Only the main signature is needed. The entire operation can be initiated and approved by a single cosignatory because the multisig was configured with a minimum removal requirement of one signature.

The cosignatories could also have been removed in the opposite order, as both have equal authority. The only difference would be which account signs the transaction and pays the transaction fee.

Submitting the Aggregate Transaction⚓︎

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

    # Announce and wait for confirmation
    transaction_hash = facade.hash_transaction(transaction)
    print(f'Built aggregate transaction with hash: {transaction_hash}')
    announce_transaction(json_payload, 'aggregate transaction')
    wait_for_confirmation(transaction_hash, 'aggregate transaction')
    // Announce and wait for confirmation
    const transactionHash =
        facade.hashTransaction(transaction).toString();
    console.log(
        'Built aggregate transaction with hash:', transactionHash);
    await announceTransaction(payload, 'aggregate transaction');
    await waitForConfirmation(transactionHash, 'aggregate transaction');

Output⚓︎

The output shown below corresponds to two typical runs of the program.

Using node https://reference.symboltest.net:3001
Multisig address: TB6QOVCUOFRCF5QJSKPIQMLUVWGJS3KYFDETRPA (public key 4CB5ABF6AD79FBF5ABBCCAFCC269D85CD2651ED4B885B5869F241AEDF0A5BA29)
Cosignatory 0 address: TBBHGE77IHHOIYA46B3XSORRNR2L5MLW54YO75Y (public key 7422B9887598068E32C4448A949ADB290D0F4E35B9E01B0EE5F1A1E600FE2674)
Cosignatory 1 address: TBBWZ2X4EXGQ65XPUNWGSJX4LHW5NWMPDNGUERY (public key F381626E41E7027EA431BFE3009E94BDD25A746BEEC468948D6C3C7C5DC9A54B)
Fetching current network time from /node/time
  Network time: 102518284736 ms since nemesis
Fetching recommended fees from /network/fees/transaction
  Fee multiplier: 100
Getting cosignatories from /account/TB6QOVCUOFRCF5QJSKPIQMLUVWGJS3KYFDETRPA/multisig
  Response: No cosignatories
Enabling the multisig with the aggregate transaction:
{
  "signature": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
  "signer_public_key": "4CB5ABF6AD79FBF5ABBCCAFCC269D85CD2651ED4B885B5869F241AEDF0A5BA29",
  "version": 3,
  "network": 152,
  "type": 16705,
  "fee": "48000",
  "deadline": "102525484736",
  "transactions_hash": "08E301DCCE53F1A3317E72EDF2E318281B040434511547910204E40AA54FB701",
  "transactions": [
    {
      "signer_public_key": "4CB5ABF6AD79FBF5ABBCCAFCC269D85CD2651ED4B885B5869F241AEDF0A5BA29",
      "version": 1,
      "network": 152,
      "type": 16725,
      "min_removal_delta": 1,
      "min_approval_delta": 1,
      "address_additions": [
        "98427313FF41CEE4601CF077793A316C74BEB176EF30EFF7",
        "98436CEAFC25CD0F76EFA36C6926FC59EDD6D98F1B4D4247"
      ],
      "address_deletions": []
    }
  ],
  "cosignatures": []
}
Built aggregate transaction with hash: 9363F72333F34A0ACD649AECED9F916F664F16B341D308B06DB2CA0D976DCA03
Announcing aggregate transaction to /transactions
  Response: {"message":"packet 9 was pushed to the network via /transactions"}
Waiting for aggregate transaction confirmation...
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  ...
  Transaction status: confirmed
  aggregate transaction confirmed in 34 seconds

Key points in the output:

  • Lines 2-4: Addresses and public keys of all involved accounts.
  • Line 10 (Response: No cosignatories): No cosignatories are currently configured.
  • Line 27 ("min_approval_delta": 1): The number of required signatures to approve transactions will be increased by one.
  • Line 28 ("min_removal_delta": 1): The number of required signatures to remove a cosignatory will be increased by one.
  • Line 29 ("address_additions"): List of addresses that will be added as cosignatories.
Using node https://reference.symboltest.net:3001
Multisig address: TB6QOVCUOFRCF5QJSKPIQMLUVWGJS3KYFDETRPA (public key 4CB5ABF6AD79FBF5ABBCCAFCC269D85CD2651ED4B885B5869F241AEDF0A5BA29)
Cosignatory 0 address: TBBHGE77IHHOIYA46B3XSORRNR2L5MLW54YO75Y (public key 7422B9887598068E32C4448A949ADB290D0F4E35B9E01B0EE5F1A1E600FE2674)
Cosignatory 1 address: TBBWZ2X4EXGQ65XPUNWGSJX4LHW5NWMPDNGUERY (public key F381626E41E7027EA431BFE3009E94BDD25A746BEEC468948D6C3C7C5DC9A54B)
Fetching current network time from /node/time
  Network time: 102516815591 ms since nemesis
Fetching recommended fees from /network/fees/transaction
  Fee multiplier: 100
Getting cosignatories from /account/TB6QOVCUOFRCF5QJSKPIQMLUVWGJS3KYFDETRPA/multisig
  Response: ['98427313FF41CEE4601CF077793A316C74BEB176EF30EFF7', '98436CEAFC25CD0F76EFA36C6926FC59EDD6D98F1B4D4247']
Disabling the multisig with the aggregate transaction:
{
  "signature": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
  "signer_public_key": "7422B9887598068E32C4448A949ADB290D0F4E35B9E01B0EE5F1A1E600FE2674",
  "version": 3,
  "network": 152,
  "type": 16705,
  "fee": "32800",
  "deadline": "102524015591",
  "transactions_hash": "F1985D5D2CC1DB30CE3B0AE8532874E14AFA9F6F2237FF0D95DA819F6483F34F",
  "transactions": [
    {
      "signer_public_key": "4CB5ABF6AD79FBF5ABBCCAFCC269D85CD2651ED4B885B5869F241AEDF0A5BA29",
      "version": 1,
      "network": 152,
      "type": 16725,
      "min_removal_delta": 0,
      "min_approval_delta": 0,
      "address_additions": [],
      "address_deletions": [
        "98436CEAFC25CD0F76EFA36C6926FC59EDD6D98F1B4D4247"
      ]
    },
    {
      "signer_public_key": "4CB5ABF6AD79FBF5ABBCCAFCC269D85CD2651ED4B885B5869F241AEDF0A5BA29",
      "version": 1,
      "network": 152,
      "type": 16725,
      "min_removal_delta": -1,
      "min_approval_delta": -1,
      "address_additions": [],
      "address_deletions": [
        "98427313FF41CEE4601CF077793A316C74BEB176EF30EFF7"
      ]
    }
  ],
  "cosignatures": []
}
Built aggregate transaction with hash: 2481B252AB15881368AD74BC21373205AFB0A23E4181DFB01A7D116FC9DDFFF7
Announcing aggregate transaction to /transactions
  Response: {"message":"packet 9 was pushed to the network via /transactions"}
Waiting for aggregate transaction confirmation...
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  ...
  Transaction status: confirmed
aggregate transaction confirmed in 9 seconds

Key points in the output:

  • Lines 2-4: Addresses and public keys of all involved accounts.
  • Line 10 (Response: [ ... ]): Existing cosignatories have been detected.
  • Line 27-32 (First embedded transaction): The minimum number of required signatures will remain unchanged, no new cosignatories will be added, and one existing cosignatory will be removed.
  • Line 39-44 (Second embedded transaction): The minimum number of required signatures will be decreased by one, no new cosignatories will be added, and the last remaining cosignatory will be removed.

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

Conclusion⚓︎

This tutorial showed how to:

Step Related documentation
Retrieve the current multisig configuration /account/{address}/multisig GET
Enable a multisig account MultisigAccountModificationTransaction
Disable a multisig account MultisigAccountModificationTransaction
Wrap configuration in an embedded transaction