Skip to content

Configuring a Multisignature Account⚓︎

ADVANCED

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

Prerequisites⚓︎

Before you start, make sure to:

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

Full Code⚓︎

import json
import os
import time
import urllib.request

from symbolchain.CryptoTypes import PrivateKey
from symbolchain.facade.NemFacade import NemFacade
from symbolchain.nc import Amount
from symbolchain.nem.FeeCalculator import calculate_transaction_fee
from symbolchain.nem.Network import NetworkTimestamp

NODE_URL = os.getenv('NODE_URL', 'http://libertalia.nemtest.net:7890')
print(f'Using node {NODE_URL}')

facade = NemFacade('testnet')

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

# Set up 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 = NemFacade.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 = []
for i in range(2):
    COSIGNATORY_PRIVATE_KEY = os.getenv(
        f'COSIGNATORY{i}_PRIVATE_KEY', KEY_TEMPLATE.format(i + 2))
    key_pair = NemFacade.KeyPair(PrivateKey(COSIGNATORY_PRIVATE_KEY))
    cosignatory_key_pairs.append(key_pair)
    addr = facade.network.public_key_to_address(key_pair.public_key)
    print(f'Cosignatory {i} address: '
        f'{addr} (public key {key_pair.public_key})')


# Helper function to announce a transaction
def announce_transaction(payload, label):
    announce_path = '/transaction/announce'
    print(f'Announcing {label} to {announce_path}')
    request = urllib.request.Request(
        f'{NODE_URL}{announce_path}',
        data=payload.encode(),
        headers={'Content-Type': 'application/json'},
        method='POST'
    )
    with urllib.request.urlopen(request) as announce_response:
        result = json.loads(announce_response.read().decode())
    print(f'  Result: {result["message"]}')
    return result['message']


# Helper function to wait for transaction confirmation
def wait_for_confirmation(tx_hash, label):
    status_path = f'/transaction/get?hash={tx_hash}'
    print(f'Waiting for {label} confirmation from {status_path}')
    is_confirmed = False
    for _ in range(120):
        try:
            with urllib.request.urlopen(
                f'{NODE_URL}{status_path}'
            ) as status_response:
                confirmed = json.loads(status_response.read().decode())
                height = confirmed['meta']['height']
                print(f'{label} confirmed in block {height}')
                is_confirmed = True
                break
        except urllib.error.HTTPError:
            print('  Transaction status: pending')
        time.sleep(1)
    if not is_confirmed:
        print(f'{label} confirmation took too long.')


# Returns the cosignatory addresses of the provided multisig
# account, or an empty list if the account is not multisig
def get_multisig_cosignatories(address):
    account_path = f'/account/get?address={address}'
    print(f'Getting cosignatories from {account_path}')
    url = f'{NODE_URL}{account_path}'
    with urllib.request.urlopen(url) as account_response:
        account_info = json.loads(account_response.read().decode())
        found_cosignatories = [
            cosignatory['address']
            for cosignatory in account_info['meta']['cosignatories']
        ]
        if not found_cosignatories:
            print('  Response: No cosignatories')
            return []
        print(f'  Response: {found_cosignatories}')
        return found_cosignatories



# Returns a transaction that turns a regular account into a multisig
def multisig_enable_transaction(tx_timestamp, tx_deadline,
        approval_delta):
    # Create a multisig account modification transaction
    # that adds the cosignatories
    modifications = [
        {'modification': {
            'modification_type': 'add_cosignatory',
            'cosignatory_public_key': key_pair.public_key
        }}
        for key_pair in cosignatory_key_pairs
    ]
    transaction = facade.transaction_factory.create({
        'type': 'multisig_account_modification_transaction_v2',
        # This is the account that will be turned into a multisig
        'signer_public_key': multisig_key_pair.public_key,
        'timestamp': tx_timestamp.timestamp,
        'deadline': tx_deadline.timestamp,
        # Change of the number of cosignatures
        # required to approve transactions
        'min_approval_delta': approval_delta,
        'modifications': modifications
    })

    # Calculate and attach the transaction fee
    fee = calculate_transaction_fee(transaction)
    transaction.fee = Amount(fee)
    print(f'  Transaction fee: {fee / 1_000_000} XEM')
    print('Enabling the multisig with the modification transaction:')
    print(json.dumps(transaction.to_json(), indent=2))

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



# Returns a transaction that removes one cosignatory from the multisig
def multisig_removal_transaction(tx_timestamp, tx_deadline,
        removed_key_pair, approval_delta):
    # Create a multisig account modification transaction
    # that removes a single cosignatory
    inner_transaction = facade.transaction_factory.create({
        'type': 'multisig_account_modification_transaction_v2',
        # This is the multisig account that will be modified
        'signer_public_key': multisig_key_pair.public_key,
        'timestamp': tx_timestamp.timestamp,
        'deadline': tx_deadline.timestamp,
        # Change of the number of cosignatures
        # required to approve transactions
        'min_approval_delta': approval_delta,
        'modifications': [
            {'modification': {
                'modification_type': 'delete_cosignatory',
                'cosignatory_public_key': removed_key_pair.public_key
            }}
        ]
    })

    # Wrap the modification in a multisig transaction
    inner_fee = calculate_transaction_fee(inner_transaction)
    inner_transaction.fee = Amount(inner_fee)
    transaction = facade.transaction_factory.create({
        'type': 'multisig_transaction_v1',
        # This is the cosignatory that initiates the removal
        'signer_public_key': cosignatory_key_pairs[0].public_key,
        'timestamp': tx_timestamp.timestamp,
        'deadline': tx_deadline.timestamp,
        'inner_transaction':
            facade.transaction_factory.to_non_verifiable_transaction(
                inner_transaction)
    })

    # Calculate and attach the transaction fee
    fee = calculate_transaction_fee(transaction)
    transaction.fee = Amount(fee)
    print(f'  Transaction fee: {(inner_fee + fee) / 1_000_000} XEM')
    print('Disabling the multisig with the multisig transaction:')
    print(json.dumps(transaction.to_json(), indent=2))

    # Sign the transaction with the cosignatory's key
    signature = facade.sign_transaction(
        cosignatory_key_pairs[0], transaction)
    facade.transaction_factory.attach_signature(transaction, signature)
    return transaction


try:
    # Fetch current network time
    time_path = '/time-sync/network-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())
        network_time = response_json['receiveTimeStamp'] // 1000
        print(f'  Network time: {network_time} s since the nemesis block')

    # Derived fields from network time
    timestamp = NetworkTimestamp(network_time)
    deadline = timestamp.add_hours(2)

    # 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
        transactions = [multisig_enable_transaction(
            timestamp, deadline, 1)]
    else:
        # Disable the multisig
        transactions = [
            multisig_removal_transaction(
                timestamp, deadline, cosignatory_key_pairs[1], 0),
            multisig_removal_transaction(
                timestamp, deadline, cosignatory_key_pairs[0], -1)
        ]

    # Announce each transaction and wait for confirmation
    for signed_transaction in transactions:
        transaction_hash = facade.hash_transaction(signed_transaction)
        print(f'Built transaction with hash: {transaction_hash}')
        json_payload = facade.transaction_factory.to_json(
            signed_transaction)
        announce_result = announce_transaction(
            json_payload, 'transaction')
        if 'SUCCESS' != announce_result:
            print('Transaction rejected')
            break
        wait_for_confirmation(transaction_hash, 'transaction')

except Exception as e:
    print(e)

Download source

import { PrivateKey } from 'symbol-sdk';
import {
    NemFacade,
    NetworkTimestamp,
    calculateTransactionFee,
    models
} from 'symbol-sdk/nem';

const NODE_URL = process.env.NODE_URL ||
    'http://libertalia.nemtest.net:7890';
console.log('Using node', NODE_URL);

const facade = new NemFacade('testnet');

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

// Set up 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 NemFacade.KeyPair(
    new PrivateKey(MULTISIG_PRIVATE_KEY));
const multisigAddress = facade.network.publicKeyToAddress(
    multisigKeyPair.publicKey);
console.log(`Multisig address: ${multisigAddress}`,
    `(public key ${multisigKeyPair.publicKey})`);

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

// Helper function to announce a transaction
async function announceTransaction(payload, label) {
    const announcePath = '/transaction/announce';
    console.log(`Announcing ${label} to ${announcePath}`);
    const announceResponse = await fetch(`${NODE_URL}${announcePath}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: payload
    });
    const result = await announceResponse.json();
    console.log('  Result:', result.message);
    return result.message;
}

// Helper function to wait for transaction confirmation
async function waitForConfirmation(transactionHash, label) {
    const statusPath = `/transaction/get?hash=${transactionHash}`;
    console.log(`Waiting for ${label} confirmation from`, statusPath);
    let isConfirmed = false;
    for (let attempt = 1; 120 >= attempt; ++attempt) {
        const response = await fetch(`${NODE_URL}${statusPath}`);
        if (response.ok) {
            const confirmed = await response.json();
            console.log(`${label} confirmed in block`,
                confirmed.meta.height);
            isConfirmed = true;
            break;
        }
        console.log('  Transaction status: pending');
        await new Promise(resolve => { setTimeout(resolve, 1000); });
    }
    if (!isConfirmed)
        console.warn(`${label} confirmation took too long.`);
}

// Returns the cosignatory addresses of the provided multisig
// account, or an empty list if the account is not multisig
async function getMultisigCosignatories(address) {
    const accountPath = `/account/get?address=${address}`;
    console.log(`Getting cosignatories from ${accountPath}`);
    const response = await fetch(`${NODE_URL}${accountPath}`);
    const accountInfo = await response.json();
    const foundCosignatories = accountInfo.meta.cosignatories
        .map(cosignatory => cosignatory.address);
    if (0 === foundCosignatories.length) {
        console.log('  Response: No cosignatories');
        return [];
    }
    console.log('  Response:', JSON.stringify(foundCosignatories));
    return foundCosignatories;
}


// Returns a transaction that turns a regular account into a multisig
function multisigEnableTransaction(timestamp, deadline, approvalDelta) {
    // Create a multisig account modification transaction
    // that adds the cosignatories
    const modifications = cosignatoryKeyPairs.map(keyPair => ({
        modification: {
            modificationType: 'add_cosignatory',
            cosignatoryPublicKey: keyPair.publicKey.toString()
        }
    }));
    const transaction = facade.transactionFactory.create({
        type: 'multisig_account_modification_transaction_v2',
        // This is the account that will be turned into a multisig
        signerPublicKey: multisigKeyPair.publicKey.toString(),
        timestamp: timestamp.timestamp,
        deadline: deadline.timestamp,
        // Change of the number of cosignatures
        // required to approve transactions
        minApprovalDelta: approvalDelta,
        modifications
    });

    // Calculate and attach the transaction fee
    const fee = calculateTransactionFee(transaction);
    transaction.fee = new models.Amount(fee);
    console.log(`  Transaction fee: ${Number(fee) / 1_000_000} XEM`);
    console.log(
        'Enabling the multisig with the modification transaction:');
    console.log(JSON.stringify(transaction.toJson(), null, 2));

    // Sign the transaction with the multisig's key
    const signature = facade.signTransaction(
        multisigKeyPair, transaction);
    facade.transactionFactory.static.attachSignature(
        transaction, signature);
    return transaction;
}


// Returns a transaction that removes one cosignatory from the multisig
function multisigRemovalTransaction(timestamp, deadline,
    removedKeyPair, approvalDelta) {
    // Create a multisig account modification transaction
    // that removes a single cosignatory
    const innerTransaction = facade.transactionFactory.create({
        type: 'multisig_account_modification_transaction_v2',
        // This is the multisig account that will be modified
        signerPublicKey: multisigKeyPair.publicKey.toString(),
        timestamp: timestamp.timestamp,
        deadline: deadline.timestamp,
        // Change of the number of cosignatures
        // required to approve transactions
        minApprovalDelta: approvalDelta,
        modifications: [
            {
                modification: {
                    modificationType: 'delete_cosignatory',
                    cosignatoryPublicKey:
                        removedKeyPair.publicKey.toString()
                }
            }
        ]
    });

    // Wrap the modification in a multisig transaction
    const innerFee = calculateTransactionFee(innerTransaction);
    innerTransaction.fee = new models.Amount(innerFee);
    const transaction = facade.transactionFactory.create({
        type: 'multisig_transaction_v1',
        // This is the cosignatory that initiates the removal
        signerPublicKey: cosignatoryKeyPairs[0].publicKey.toString(),
        timestamp: timestamp.timestamp,
        deadline: deadline.timestamp,
        innerTransaction: facade.transactionFactory.static
            .toNonVerifiableTransaction(innerTransaction)
    });

    // Calculate and attach the transaction fee
    const fee = calculateTransactionFee(transaction);
    transaction.fee = new models.Amount(fee);
    console.log('  Transaction fee:',
        `${Number(innerFee + fee) / 1_000_000} XEM`);
    console.log(
        'Disabling the multisig with the multisig transaction:');
    console.log(JSON.stringify(transaction.toJson(), null, 2));

    // Sign the transaction with the cosignatory's key
    const signature = facade.signTransaction(
        cosignatoryKeyPairs[0], transaction);
    facade.transactionFactory.static
        .attachSignature(transaction, signature);
    return transaction;
}

try {
    // Fetch current network time
    const timePath = '/time-sync/network-time';
    console.log('Fetching current network time from', timePath);
    const timeResponse = await fetch(`${NODE_URL}${timePath}`);
    const timeJSON = await timeResponse.json();
    const networkTime = Math.floor(timeJSON.receiveTimeStamp / 1000);
    console.log('  Network time:', networkTime,
        's since the nemesis block');

    // Derived fields from network time
    const timestamp = new NetworkTimestamp(networkTime);
    const deadline = timestamp.addHours(2);

    // Get current state of the multisig account and decide
    // which operation to perform
    const cosignatories = await getMultisigCosignatories(multisigAddress);
    let transactions;
    if (0 === cosignatories.length) {
        // Enable the multisig
        transactions = [multisigEnableTransaction(
            timestamp, deadline, 1)];
    } else {
        // Disable the multisig
        transactions = [
            multisigRemovalTransaction(
                timestamp, deadline, cosignatoryKeyPairs[1], 0),
            multisigRemovalTransaction(
                timestamp, deadline, cosignatoryKeyPairs[0], -1)
        ];
    }

    // Announce each transaction and wait for confirmation
    for (const signedTransaction of transactions) {
        const transactionHash = facade.hashTransaction(signedTransaction)
            .toString();
        console.log('Built transaction with hash:', transactionHash);
        const jsonPayload = facade.transactionFactory.static
            .toJson(signedTransaction);
        const result = await announceTransaction(
            jsonPayload, 'transaction');
        if ('SUCCESS' !== result) {
            console.log('Transaction rejected');
            break;
        }
        await waitForConfirmation(transactionHash, 'transaction');
    }

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

Download source

Code Explanation⚓︎

The code defines two helper functions, for announcing a transaction and waiting for its confirmation. For details on how these work, see the Transfer XEM 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 time, and detect the current configuration of the multisig account.

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

Setting Up the Accounts⚓︎

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

# Set up 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 = NemFacade.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 = []
for i in range(2):
    COSIGNATORY_PRIVATE_KEY = os.getenv(
        f'COSIGNATORY{i}_PRIVATE_KEY', KEY_TEMPLATE.format(i + 2))
    key_pair = NemFacade.KeyPair(PrivateKey(COSIGNATORY_PRIVATE_KEY))
    cosignatory_key_pairs.append(key_pair)
    addr = facade.network.public_key_to_address(key_pair.public_key)
    print(f'Cosignatory {i} address: '
        f'{addr} (public key {key_pair.public_key})')
const KEY_PREFIX = '0'.repeat(63);

// Set up 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 NemFacade.KeyPair(
    new PrivateKey(MULTISIG_PRIVATE_KEY));
const multisigAddress = facade.network.publicKeyToAddress(
    multisigKeyPair.publicKey);
console.log(`Multisig address: ${multisigAddress}`,
    `(public key ${multisigKeyPair.publicKey})`);

const cosignatoryKeyPairs = [];
for (let i = 0; 2 > i; i++) {
    const COSIGNATORY_PRIVATE_KEY =
        process.env[`COSIGNATORY${i}_PRIVATE_KEY`] || (
            KEY_PREFIX + String(i + 2));
    const keyPair = new NemFacade.KeyPair(
        new PrivateKey(COSIGNATORY_PRIVATE_KEY));
    cosignatoryKeyPairs.push(keyPair);
    const addr = facade.network.publicKeyToAddress(keyPair.publicKey);
    console.log(`Cosignatory ${i} address: ${addr}`,
        `(public key ${keyPair.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 must hold enough funds to pay the transaction fees. If the default values are used, this account may already be funded.

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

Fetching Network Time⚓︎

    # Fetch current network time
    time_path = '/time-sync/network-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())
        network_time = response_json['receiveTimeStamp'] // 1000
        print(f'  Network time: {network_time} s since the nemesis block')

    # Derived fields from network time
    timestamp = NetworkTimestamp(network_time)
    deadline = timestamp.add_hours(2)
    // Fetch current network time
    const timePath = '/time-sync/network-time';
    console.log('Fetching current network time from', timePath);
    const timeResponse = await fetch(`${NODE_URL}${timePath}`);
    const timeJSON = await timeResponse.json();
    const networkTime = Math.floor(timeJSON.receiveTimeStamp / 1000);
    console.log('  Network time:', networkTime,
        's since the nemesis block');

    // Derived fields from network time
    const timestamp = new NetworkTimestamp(networkTime);
    const deadline = timestamp.addHours(2);

Network time is fetched from /time-sync/network-time GET, and the transactions' timestamp and deadline fields are derived from it, following the process described in the Transfer XEM tutorial.

Determining the Multisig Operation⚓︎

# Returns the cosignatory addresses of the provided multisig
# account, or an empty list if the account is not multisig
def get_multisig_cosignatories(address):
    account_path = f'/account/get?address={address}'
    print(f'Getting cosignatories from {account_path}')
    url = f'{NODE_URL}{account_path}'
    with urllib.request.urlopen(url) as account_response:
        account_info = json.loads(account_response.read().decode())
        found_cosignatories = [
            cosignatory['address']
            for cosignatory in account_info['meta']['cosignatories']
        ]
        if not found_cosignatories:
            print('  Response: No cosignatories')
            return []
        print(f'  Response: {found_cosignatories}')
        return found_cosignatories
// Returns the cosignatory addresses of the provided multisig
// account, or an empty list if the account is not multisig
async function getMultisigCosignatories(address) {
    const accountPath = `/account/get?address=${address}`;
    console.log(`Getting cosignatories from ${accountPath}`);
    const response = await fetch(`${NODE_URL}${accountPath}`);
    const accountInfo = await response.json();
    const foundCosignatories = accountInfo.meta.cosignatories
        .map(cosignatory => cosignatory.address);
    if (0 === foundCosignatories.length) {
        console.log('  Response: No cosignatories');
        return [];
    }
    console.log('  Response:', JSON.stringify(foundCosignatories));
    return foundCosignatories;
}

This helper retrieves the list of current cosignatories for a given address using the /account/get GET endpoint. If it returns an empty list, the account is not currently configured as a multisig account.

Check the existing multisig configuration

For simplicity, the tutorial assumes that if the list of cosignatories is not empty, then the account is a multisig configured by the tutorial itself.

If the configuration is not the expected one, for example, because the cosignatories are different, the removal transactions will be rejected.

Applications should always check the current configuration before trying to modify it, including the full list of cosignatories and the minimum number of signatures required.

    # 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
        transactions = [multisig_enable_transaction(
            timestamp, deadline, 1)]
    else:
        # Disable the multisig
        transactions = [
            multisig_removal_transaction(
                timestamp, deadline, cosignatory_key_pairs[1], 0),
            multisig_removal_transaction(
                timestamp, deadline, cosignatory_key_pairs[0], -1)
        ]
    // Get current state of the multisig account and decide
    // which operation to perform
    const cosignatories = await getMultisigCosignatories(multisigAddress);
    let transactions;
    if (0 === cosignatories.length) {
        // Enable the multisig
        transactions = [multisigEnableTransaction(
            timestamp, deadline, 1)];
    } else {
        // Disable the multisig
        transactions = [
            multisigRemovalTransaction(
                timestamp, deadline, cosignatoryKeyPairs[1], 0),
            multisigRemovalTransaction(
                timestamp, deadline, cosignatoryKeyPairs[0], -1)
        ];
    }

The returned cosignatories determine whether the account is configured as a multisig account, and therefore whether to create the transactions to enable or disable multisig.

The functions that build them and the delta values they use are described in the next two sections.

Enabling the Multisig⚓︎

# Returns a transaction that turns a regular account into a multisig
def multisig_enable_transaction(tx_timestamp, tx_deadline,
        approval_delta):
    # Create a multisig account modification transaction
    # that adds the cosignatories
    modifications = [
        {'modification': {
            'modification_type': 'add_cosignatory',
            'cosignatory_public_key': key_pair.public_key
        }}
        for key_pair in cosignatory_key_pairs
    ]
    transaction = facade.transaction_factory.create({
        'type': 'multisig_account_modification_transaction_v2',
        # This is the account that will be turned into a multisig
        'signer_public_key': multisig_key_pair.public_key,
        'timestamp': tx_timestamp.timestamp,
        'deadline': tx_deadline.timestamp,
        # Change of the number of cosignatures
        # required to approve transactions
        'min_approval_delta': approval_delta,
        'modifications': modifications
    })
// Returns a transaction that turns a regular account into a multisig
function multisigEnableTransaction(timestamp, deadline, approvalDelta) {
    // Create a multisig account modification transaction
    // that adds the cosignatories
    const modifications = cosignatoryKeyPairs.map(keyPair => ({
        modification: {
            modificationType: 'add_cosignatory',
            cosignatoryPublicKey: keyPair.publicKey.toString()
        }
    }));
    const transaction = facade.transactionFactory.create({
        type: 'multisig_account_modification_transaction_v2',
        // This is the account that will be turned into a multisig
        signerPublicKey: multisigKeyPair.publicKey.toString(),
        timestamp: timestamp.timestamp,
        deadline: deadline.timestamp,
        // Change of the number of cosignatures
        // required to approve transactions
        minApprovalDelta: approvalDelta,
        modifications
    });

All changes to the multisig configuration of an account, including adding or removing cosignatories, are performed using a MultisigAccountModificationTransactionV2.

The transaction specifies:

  • : Multisig configuration changes use the type MultisigAccountModificationTransactionV2.

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

  • and : The values computed in the network time step.

  • : difference between the desired value and the current value of the number of cosignatures required to approve transactions from the multisig account.

    In this case, the account is initially a regular account, so the current number of required cosignatures 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.

  • : list of changes to the account's cosignatories. Each modification adds or removes one cosignatory, identified by its public key.

    In this case, two add_cosignatory modifications add the cosignatories 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 number of required cosignatures
  • An account that is already a cosignatory is added
  • An account that is not a cosignatory is removed
  • More than one cosignatory is removed in a single transaction
  • A multisig account is added as a cosignatory
    # Calculate and attach the transaction fee
    fee = calculate_transaction_fee(transaction)
    transaction.fee = Amount(fee)
    print(f'  Transaction fee: {fee / 1_000_000} XEM')
    print('Enabling the multisig with the modification transaction:')
    print(json.dumps(transaction.to_json(), indent=2))
    // Calculate and attach the transaction fee
    const fee = calculateTransactionFee(transaction);
    transaction.fee = new models.Amount(fee);
    console.log(`  Transaction fee: ${Number(fee) / 1_000_000} XEM`);
    console.log(
        'Enabling the multisig with the modification transaction:');
    console.log(JSON.stringify(transaction.toJson(), null, 2));

The transaction fee is calculated with and attached to the transaction. Multisig account modification transactions pay a fixed transaction fee of 0.5 XEM, as shown in the fee schedule.

    # Sign the transaction with the multisig's key
    signature = facade.sign_transaction(multisig_key_pair, transaction)
    facade.transaction_factory.attach_signature(transaction, signature)
    return transaction
    // Sign the transaction with the multisig's key
    const signature = facade.signTransaction(
        multisigKeyPair, transaction);
    facade.transactionFactory.static.attachSignature(
        transaction, signature);
    return transaction;

Finally, the transaction is signed. In this case, only the signature of the account being converted into a multisig is required. The cosignatories do not sign the conversion transaction.

From now on, cosignatories must initiate transactions

Once an account has multisig enabled, its own signature is no longer accepted. Any transaction sent from that account, such as a transfer or a further multisig modification, must instead be initiated and signed by its cosignatories, as shown in the next section.

Disabling the Multisig⚓︎

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

# Returns a transaction that removes one cosignatory from the multisig
def multisig_removal_transaction(tx_timestamp, tx_deadline,
        removed_key_pair, approval_delta):
    # Create a multisig account modification transaction
    # that removes a single cosignatory
    inner_transaction = facade.transaction_factory.create({
        'type': 'multisig_account_modification_transaction_v2',
        # This is the multisig account that will be modified
        'signer_public_key': multisig_key_pair.public_key,
        'timestamp': tx_timestamp.timestamp,
        'deadline': tx_deadline.timestamp,
        # Change of the number of cosignatures
        # required to approve transactions
        'min_approval_delta': approval_delta,
        'modifications': [
            {'modification': {
                'modification_type': 'delete_cosignatory',
                'cosignatory_public_key': removed_key_pair.public_key
            }}
        ]
    })
// Returns a transaction that removes one cosignatory from the multisig
function multisigRemovalTransaction(timestamp, deadline,
    removedKeyPair, approvalDelta) {
    // Create a multisig account modification transaction
    // that removes a single cosignatory
    const innerTransaction = facade.transactionFactory.create({
        type: 'multisig_account_modification_transaction_v2',
        // This is the multisig account that will be modified
        signerPublicKey: multisigKeyPair.publicKey.toString(),
        timestamp: timestamp.timestamp,
        deadline: deadline.timestamp,
        // Change of the number of cosignatures
        // required to approve transactions
        minApprovalDelta: approvalDelta,
        modifications: [
            {
                modification: {
                    modificationType: 'delete_cosignatory',
                    cosignatoryPublicKey:
                        removedKeyPair.publicKey.toString()
                }
            }
        ]
    });

This helper builds a MultisigAccountModificationTransactionV2 that removes a cosignatory. It takes the cosignatory to remove and the approval delta to apply as parameters. is set to the multisig account's public key because its configuration is being modified.

As shown in Determining the Multisig Operation, the helper is called twice.

The first call removes with an approval delta of 0, because one cosignatory still remains.

The second removes the remaining cosignatory with an approval delta of -1, reducing the approval requirement from 1 back to 0.

    # Wrap the modification in a multisig transaction
    inner_fee = calculate_transaction_fee(inner_transaction)
    inner_transaction.fee = Amount(inner_fee)
    transaction = facade.transaction_factory.create({
        'type': 'multisig_transaction_v1',
        # This is the cosignatory that initiates the removal
        'signer_public_key': cosignatory_key_pairs[0].public_key,
        'timestamp': tx_timestamp.timestamp,
        'deadline': tx_deadline.timestamp,
        'inner_transaction':
            facade.transaction_factory.to_non_verifiable_transaction(
                inner_transaction)
    })
    // Wrap the modification in a multisig transaction
    const innerFee = calculateTransactionFee(innerTransaction);
    innerTransaction.fee = new models.Amount(innerFee);
    const transaction = facade.transactionFactory.create({
        type: 'multisig_transaction_v1',
        // This is the cosignatory that initiates the removal
        signerPublicKey: cosignatoryKeyPairs[0].publicKey.toString(),
        timestamp: timestamp.timestamp,
        deadline: deadline.timestamp,
        innerTransaction: facade.transactionFactory.static
            .toNonVerifiableTransaction(innerTransaction)
    });

Since a multisig account cannot sign transactions on its own, each modification is wrapped in a MultisigTransactionV1.

The inner modification transaction is converted with so it can be embedded in the wrapping multisig transaction.

    # Calculate and attach the transaction fee
    fee = calculate_transaction_fee(transaction)
    transaction.fee = Amount(fee)
    print(f'  Transaction fee: {(inner_fee + fee) / 1_000_000} XEM')
    print('Disabling the multisig with the multisig transaction:')
    print(json.dumps(transaction.to_json(), indent=2))
    // Calculate and attach the transaction fee
    const fee = calculateTransactionFee(transaction);
    transaction.fee = new models.Amount(fee);
    console.log('  Transaction fee:',
        `${Number(innerFee + fee) / 1_000_000} XEM`);
    console.log(
        'Disabling the multisig with the multisig transaction:');
    console.log(JSON.stringify(transaction.toJson(), null, 2));

Both the inner transaction and the wrapper pay a transaction fee: 0.5 XEM for the modification and 0.15 XEM for the multisig wrapper, as shown in the fee schedule. Both fees are deducted from the multisig account. Cosignatories never pay fees for the transactions they initiate on behalf of a multisig.

    # Sign the transaction with the cosignatory's key
    signature = facade.sign_transaction(
        cosignatory_key_pairs[0], transaction)
    facade.transaction_factory.attach_signature(transaction, signature)
    return transaction
    // Sign the transaction with the cosignatory's key
    const signature = facade.signTransaction(
        cosignatoryKeyPairs[0], transaction);
    facade.transactionFactory.static
        .attachSignature(transaction, signature);
    return transaction;

Finally, the multisig transaction is signed by a cosignatory. In this case, both multisig transactions are initiated by , whose signature alone is enough to approve them without additional cosignatures.

The cosignatories could also have been removed in the opposite order. The only difference would be which cosignatory initiates and signs each transaction.

Submitting the Transactions⚓︎

    # Announce each transaction and wait for confirmation
    for signed_transaction in transactions:
        transaction_hash = facade.hash_transaction(signed_transaction)
        print(f'Built transaction with hash: {transaction_hash}')
        json_payload = facade.transaction_factory.to_json(
            signed_transaction)
        announce_result = announce_transaction(
            json_payload, 'transaction')
        if 'SUCCESS' != announce_result:
            print('Transaction rejected')
            break
        wait_for_confirmation(transaction_hash, 'transaction')
    // Announce each transaction and wait for confirmation
    for (const signedTransaction of transactions) {
        const transactionHash = facade.hashTransaction(signedTransaction)
            .toString();
        console.log('Built transaction with hash:', transactionHash);
        const jsonPayload = facade.transactionFactory.static
            .toJson(signedTransaction);
        const result = await announceTransaction(
            jsonPayload, 'transaction');
        if ('SUCCESS' !== result) {
            console.log('Transaction rejected');
            break;
        }
        await waitForConfirmation(transactionHash, 'transaction');
    }

The final step is to announce the transactions and wait for their confirmation, as described in the Transfer XEM tutorial.

When disabling the multisig, the two multisig transactions are announced sequentially. The code waits for the first transaction to be confirmed before announcing the second one, because the second removal is only valid once the first one has been processed.

Output⚓︎

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

Using node http://libertalia.nemtest.net:7890
Multisig address: TBLXIOUO4EP5YR74HYXS3BFBGONZBUHP3NIS2HJ6 (public key D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2)
Cosignatory 0 address: TAWOQNIMCCFO6MT7JLLFER746HKBBUVU7KQUSDJX (public key AC1FC0D95CA3255D20C57C179EE6E694A47A725C48DB362CC4978D7745C6A5C3)
Cosignatory 1 address: TC7BQFXISQEOPN2PCPPPOM3V4R3XDPNVEHEQLID4 (public key 26D999AD34795F20D33886047A8CB7DE1ED0042AB7ED1017C602222C8B2A4C23)
Fetching current network time from /time-sync/network-time
  Network time: 357324103 s since the nemesis block
Getting cosignatories from /account/get?address=TBLXIOUO4EP5YR74HYXS3BFBGONZBUHP3NIS2HJ6
  Response: No cosignatories
  Transaction fee: 0.5 XEM
Enabling the multisig with the modification transaction:
{
  "type": 4097,
  "version": 2,
  "network": 152,
  "timestamp": 357324103,
  "signer_public_key": "D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2",
  "signature": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
  "fee": "500000",
  "deadline": 357331303,
  "modifications": [
    {
      "modification": {
        "modification_type": 1,
        "cosignatory_public_key": "AC1FC0D95CA3255D20C57C179EE6E694A47A725C48DB362CC4978D7745C6A5C3"
      }
    },
    {
      "modification": {
        "modification_type": 1,
        "cosignatory_public_key": "26D999AD34795F20D33886047A8CB7DE1ED0042AB7ED1017C602222C8B2A4C23"
      }
    }
  ],
  "min_approval_delta": 1
}
Built transaction with hash: 4F50710F7AB5C92ED1913D0EB4EE2E29D2AA6CC9C5C6509FB1C43BFDAF124C8B
Announcing transaction to /transaction/announce
  Result: SUCCESS
Waiting for transaction confirmation from /transaction/get?hash=4F50710F7AB5C92ED1913D0EB4EE2E29D2AA6CC9C5C6509FB1C43BFDAF124C8B
  Transaction status: pending
  Transaction status: pending
  ...
transaction confirmed in block 715434

Key points in the output:

  • Lines 2-4: Addresses and public keys of all involved accounts.
  • Line 8 (Response: No cosignatories): No cosignatories are currently configured.
  • Lines 24 and 30 (cosignatory_public_key): Public keys of the cosignatories that will be added.
  • Line 34 ("min_approval_delta": 1): The number of required cosignatures will be increased by one.
Using node http://libertalia.nemtest.net:7890
Multisig address: TBLXIOUO4EP5YR74HYXS3BFBGONZBUHP3NIS2HJ6 (public key D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2)
Cosignatory 0 address: TAWOQNIMCCFO6MT7JLLFER746HKBBUVU7KQUSDJX (public key AC1FC0D95CA3255D20C57C179EE6E694A47A725C48DB362CC4978D7745C6A5C3)
Cosignatory 1 address: TC7BQFXISQEOPN2PCPPPOM3V4R3XDPNVEHEQLID4 (public key 26D999AD34795F20D33886047A8CB7DE1ED0042AB7ED1017C602222C8B2A4C23)
Fetching current network time from /time-sync/network-time
  Network time: 357324204 s since the nemesis block
Getting cosignatories from /account/get?address=TBLXIOUO4EP5YR74HYXS3BFBGONZBUHP3NIS2HJ6
  Response: ['TC7BQFXISQEOPN2PCPPPOM3V4R3XDPNVEHEQLID4', 'TAWOQNIMCCFO6MT7JLLFER746HKBBUVU7KQUSDJX']
  Transaction fee: 0.65 XEM
Disabling the multisig with the multisig transaction:
{
  "type": 4100,
  "version": 1,
  "network": 152,
  "timestamp": 357324204,
  "signer_public_key": "AC1FC0D95CA3255D20C57C179EE6E694A47A725C48DB362CC4978D7745C6A5C3",
  "signature": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
  "fee": "150000",
  "deadline": 357331404,
  "inner_transaction": {
    "type": 4097,
    "version": 2,
    "network": 152,
    "timestamp": 357324204,
    "signer_public_key": "D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2",
    "signature": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
    "fee": "500000",
    "deadline": 357331404,
    "modifications": [
      {
        "modification": {
          "modification_type": 2,
          "cosignatory_public_key": "26D999AD34795F20D33886047A8CB7DE1ED0042AB7ED1017C602222C8B2A4C23"
        }
      }
    ],
    "min_approval_delta": 0
  },
  "cosignatures": []
}
  Transaction fee: 0.65 XEM
Disabling the multisig with the multisig transaction:
{
  "type": 4100,
  "version": 1,
  "network": 152,
  "timestamp": 357324204,
  "signer_public_key": "AC1FC0D95CA3255D20C57C179EE6E694A47A725C48DB362CC4978D7745C6A5C3",
  "signature": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
  "fee": "150000",
  "deadline": 357331404,
  "inner_transaction": {
    "type": 4097,
    "version": 2,
    "network": 152,
    "timestamp": 357324204,
    "signer_public_key": "D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2",
    "signature": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
    "fee": "500000",
    "deadline": 357331404,
    "modifications": [
      {
        "modification": {
          "modification_type": 2,
          "cosignatory_public_key": "AC1FC0D95CA3255D20C57C179EE6E694A47A725C48DB362CC4978D7745C6A5C3"
        }
      }
    ],
    "min_approval_delta": -1
  },
  "cosignatures": []
}
Built transaction with hash: E66D3B5D36D7711C5A3C0D99C502258345E086241DF7CCD810DFB4A0ED8CC90D
Announcing transaction to /transaction/announce
  Result: SUCCESS
Waiting for transaction confirmation from /transaction/get?hash=E66D3B5D36D7711C5A3C0D99C502258345E086241DF7CCD810DFB4A0ED8CC90D
  Transaction status: pending
  Transaction status: pending
  ...
transaction confirmed in block 715438
Built transaction with hash: 70CAE1EEF8432A834C0E4EBE3A1EB6A1774F4AE42939E05AE9F120A9AF456051
Announcing transaction to /transaction/announce
  Result: SUCCESS
Waiting for transaction confirmation from /transaction/get?hash=70CAE1EEF8432A834C0E4EBE3A1EB6A1774F4AE42939E05AE9F120A9AF456051
  Transaction status: pending
  Transaction status: pending
  ...
transaction confirmed in block 715439

Key points in the output:

  • Lines 2-4: Addresses and public keys of all involved accounts.
  • Line 8 (Response: [ ... ]): Existing cosignatories have been detected.
  • Lines 29-37 (First multisig transaction): The number of required cosignatures will remain unchanged and one existing cosignatory will be removed.
  • Lines 61-69 (Second multisig transaction): The number of required cosignatures will be decreased by one 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 NEM testnet explorer.

Conclusion⚓︎

This tutorial showed how to:

Step Related documentation
Retrieve the current multisig configuration /account/get GET
Enable a multisig account MultisigAccountModificationTransactionV2
Disable a multisig account MultisigAccountModificationTransactionV2
Wrap a modification in a multisig transaction MultisigTransactionV1