Skip to content

Signing a Transaction from a Multisignature Account⚓︎

INTERMEDIATE

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

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

The multisig account used in this tutorial is configured as a 2-of-2 multisig. It has two cosignatories, and both signatures are required to approve a transaction.

Cosignatory 0 initiates the transfer, and Cosignatory 1 provides the second required cosignature:

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

Alternative: WebSockets

In this tutorial, the cosignatory discovers the pending transaction by querying the node. For a WebSocket-based approach, where the cosignatory is notified in real time, see the Listening to Multisig Transaction Flow tutorial.

Prerequisites⚓︎

Before you start, make sure to:

  • Set up your development environment. See Setting Up a Development Environment.

  • Complete the Configuring a Multisignature Account tutorial.

    Configure and clean up the 2-of-2 multisig

    The multisig configured in that tutorial is a 1-of-2, where a single cosignatory signature is enough. This tutorial instead requires the stricter 2-of-2 configuration described above.

    To create it, run the configure tutorial, changing the last parameter of from 1 to 2.

    After running this tutorial, remember that the configure tutorial's default disable path assumes a 1-of-2 multisig. To disable the 2-of-2 multisig, remove cosignatory 1 first with min_approval_delta set to -1, and have both cosignatories sign that removal. Once confirmed, remove cosignatory 0 with min_approval_delta set to -1 as usual.

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


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


facade = NemFacade('testnet')

MULTISIG_PUBLIC_KEY = os.getenv(
    'MULTISIG_PUBLIC_KEY',
    'D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2')
multisig_public_key = PublicKey(MULTISIG_PUBLIC_KEY)
multisig_address = facade.network.public_key_to_address(
    multisig_public_key)
print(f'Multisig public key: {multisig_public_key}')
COSIGNATORY0_PRIVATE_KEY = os.getenv(
    'COSIGNATORY0_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000002')
cosignatory0_key_pair = NemFacade.KeyPair(
    PrivateKey(COSIGNATORY0_PRIVATE_KEY))
print(f'Cosignatory 0 public key: {cosignatory0_key_pair.public_key}')
COSIGNATORY1_PRIVATE_KEY = os.getenv(
    'COSIGNATORY1_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000003')
cosignatory1_key_pair = NemFacade.KeyPair(
    PrivateKey(COSIGNATORY1_PRIVATE_KEY))
print(f'Cosignatory 1 public key: {cosignatory1_key_pair.public_key}')


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)

    # Build the inner transfer transaction
    transfer_transaction = facade.transaction_factory.create({
        'type': 'transfer_transaction_v2',
        'signer_public_key': multisig_public_key,
        'timestamp': timestamp.timestamp,
        'deadline': deadline.timestamp,
        'recipient_address': multisig_address,
        'amount': 1_000_000  # 1 XEM
    })
    transfer_transaction.fee = Amount(
        calculate_transaction_fee(transfer_transaction))

    # Build the wrapper multisig transaction
    transaction = facade.transaction_factory.create({
        'type': 'multisig_transaction_v1',
        # This is the cosignatory that initiates the transfer
        'signer_public_key': cosignatory0_key_pair.public_key,
        'timestamp': timestamp.timestamp,
        'deadline': deadline.timestamp,
        'inner_transaction':
            facade.transaction_factory.to_non_verifiable_transaction(
                transfer_transaction)
    })
    transaction.fee = Amount(calculate_transaction_fee(transaction))

    # Sign and announce the multisig transaction
    signature = facade.sign_transaction(
        cosignatory0_key_pair, transaction)
    json_payload = facade.transaction_factory.attach_signature(
        transaction, signature)
    print('Built multisig transaction:')
    print(json.dumps(transaction.to_json(), indent=2))
    announce_result = announce_transaction(
        json_payload, 'multisig transaction')
    # The transaction is now waiting for the second signature

    # Retrieve the pending transaction from the network
    if 'SUCCESS' == announce_result:
        cosignatory1_address = facade.network.public_key_to_address(
            cosignatory1_key_pair.public_key)
        unconfirmed_path = ('/account/unconfirmedTransactions'
            f'?address={cosignatory1_address}')
        print(f'Fetching pending transactions from {unconfirmed_path}')
        with urllib.request.urlopen(
            f'{NODE_URL}{unconfirmed_path}'
        ) as response:
            pending = json.loads(response.read().decode())['data']
        # Select the pending transaction issued by the multisig account
        inner_transaction_hash = next(
            entry['meta']['data'] for entry in pending
            if entry['transaction'].get('otherTrans', {}).get(
                'signer', '').upper() == str(multisig_public_key))
        print(f'  Inner transaction hash: {inner_transaction_hash}')

        # Build the cosignature
        cosignature = facade.transaction_factory.create({
            'type': 'cosignature_v1',
            # This is the cosignatory providing the second signature
            'signer_public_key': cosignatory1_key_pair.public_key,
            'timestamp': timestamp.timestamp,
            'deadline': deadline.timestamp,
            # Hash of the inner transfer transaction
            'other_transaction_hash': inner_transaction_hash,
            # Address of the multisig account
            'multisig_account_address': multisig_address
        })
        cosignature.fee = Amount(calculate_transaction_fee(cosignature))

        # Sign and announce the cosignature
        cosignature_signature = facade.sign_transaction(
            cosignatory1_key_pair, cosignature)
        cosignature_payload = facade.transaction_factory.attach_signature(
            cosignature, cosignature_signature)
        print('Built cosignature:')
        print(json.dumps(cosignature.to_json(), indent=2))
        cosignature_result = announce_transaction(
            cosignature_payload, 'cosignature')

        # Wait for the multisig transaction to be confirmed
        if 'SUCCESS' == cosignature_result:
            wait_for_confirmation(
                facade.hash_transaction(transaction),
                'multisig transaction')
        else:
            print(f'Transaction rejected: {cosignature_result}')

    else:
        print(f'Transaction rejected: {announce_result}')
except urllib.error.URLError as e:
    print(e.reason)

Download source

import { PrivateKey, PublicKey } 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);

// 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.`);
}

const facade = new NemFacade('testnet');

const MULTISIG_PUBLIC_KEY = process.env.MULTISIG_PUBLIC_KEY || (
    'D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2');
const multisigPublicKey = new PublicKey(MULTISIG_PUBLIC_KEY);
const multisigAddress = facade.network.publicKeyToAddress(
    multisigPublicKey);
console.log(`Multisig public key: ${multisigPublicKey}`);
const COSIGNATORY0_PRIVATE_KEY = process.env.COSIGNATORY0_PRIVATE_KEY || (
    '0000000000000000000000000000000000000000000000000000000000000002');
const cosignatory0KeyPair = new NemFacade.KeyPair(
    new PrivateKey(COSIGNATORY0_PRIVATE_KEY));
console.log(`Cosignatory 0 public key: ${cosignatory0KeyPair.publicKey}`);
const COSIGNATORY1_PRIVATE_KEY = process.env.COSIGNATORY1_PRIVATE_KEY || (
    '0000000000000000000000000000000000000000000000000000000000000003');
const cosignatory1KeyPair = new NemFacade.KeyPair(
    new PrivateKey(COSIGNATORY1_PRIVATE_KEY));
console.log(`Cosignatory 1 public key: ${cosignatory1KeyPair.publicKey}`);


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

    // Build the inner transfer transaction
    const transferTransaction = facade.transactionFactory.create({
        type: 'transfer_transaction_v2',
        signerPublicKey: multisigPublicKey.toString(),
        timestamp: timestamp.timestamp,
        deadline: deadline.timestamp,
        recipientAddress: multisigAddress.toString(),
        amount: 1_000_000n // 1 XEM
    });
    transferTransaction.fee = new models.Amount(
        calculateTransactionFee(transferTransaction));

    // Build the wrapper multisig transaction
    const transaction = facade.transactionFactory.create({
        type: 'multisig_transaction_v1',
        // This is the cosignatory that initiates the transfer
        signerPublicKey: cosignatory0KeyPair.publicKey.toString(),
        timestamp: timestamp.timestamp,
        deadline: deadline.timestamp,
        innerTransaction: facade.transactionFactory.static
            .toNonVerifiableTransaction(transferTransaction)
    });
    transaction.fee = new models.Amount(
        calculateTransactionFee(transaction));

    // Sign and announce the multisig transaction
    const signature = facade.signTransaction(
        cosignatory0KeyPair, transaction);
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction, signature);
    console.log('Built multisig transaction:');
    console.log(JSON.stringify(transaction.toJson(), null, 2));
    const announceResult = await announceTransaction(
        jsonPayload, 'multisig transaction');
    // The transaction is now waiting for the second signature

    // Retrieve the pending transaction from the network
    if ('SUCCESS' === announceResult) {
        const cosignatory1Address = facade.network.publicKeyToAddress(
            cosignatory1KeyPair.publicKey);
        const unconfirmedPath = '/account/unconfirmedTransactions' +
            `?address=${cosignatory1Address}`;
        console.log('Fetching pending transactions from',
            unconfirmedPath);
        const unconfirmedResponse = await fetch(
            `${NODE_URL}${unconfirmedPath}`);
        const pending = (await unconfirmedResponse.json()).data;
        // Select the pending transaction issued by the multisig account
        const pendingEntry = pending.find(entry =>
            multisigPublicKey.toString() === (entry.transaction
                .otherTrans?.signer ?? '').toUpperCase());
        const innerTransactionHash = pendingEntry.meta.data;
        console.log('  Inner transaction hash:', innerTransactionHash);

        // Build the cosignature
        const cosignature = facade.transactionFactory.create({
            type: 'cosignature_v1',
            // This is the cosignatory providing the second signature
            signerPublicKey: cosignatory1KeyPair.publicKey.toString(),
            timestamp: timestamp.timestamp,
            deadline: deadline.timestamp,
            // Hash of the inner transfer transaction
            otherTransactionHash: innerTransactionHash,
            // Address of the multisig account
            multisigAccountAddress: multisigAddress.toString()
        });
        cosignature.fee = new models.Amount(
            calculateTransactionFee(cosignature));

        // Sign and announce the cosignature
        const cosignatureSignature = facade.signTransaction(
            cosignatory1KeyPair, cosignature);
        const cosignaturePayload = facade.transactionFactory.static
            .attachSignature(cosignature, cosignatureSignature);
        console.log('Built cosignature:');
        console.log(JSON.stringify(cosignature.toJson(), null, 2));
        const cosignatureResult = await announceTransaction(
            cosignaturePayload, 'cosignature');

        // Wait for the multisig transaction to be confirmed
        if ('SUCCESS' === cosignatureResult) {
            await waitForConfirmation(
                facade.hashTransaction(transaction).toString(),
                'multisig transaction');
        } else {
            console.log('Transaction rejected:', cosignatureResult);
        }

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

Download source

Code Explanation⚓︎

Signing a transaction on behalf of a multisig account involves wrapping it in a MultisigTransactionV1 and collecting the required cosignatures.

In this tutorial, the wrapped transaction is a transfer, with the multisig account as its signer, since this is the origin of the funds. Cosignatory 0 signs and announces the wrapper, and the transaction remains pending until Cosignatory 1 provides the second required cosignature.

In practice, each cosignatory would run its own part on a different machine, holding only its own private key. This tutorial combines both roles in a single program for simplicity.

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.

Setting Up the Accounts⚓︎

MULTISIG_PUBLIC_KEY = os.getenv(
    'MULTISIG_PUBLIC_KEY',
    'D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2')
multisig_public_key = PublicKey(MULTISIG_PUBLIC_KEY)
multisig_address = facade.network.public_key_to_address(
    multisig_public_key)
print(f'Multisig public key: {multisig_public_key}')
COSIGNATORY0_PRIVATE_KEY = os.getenv(
    'COSIGNATORY0_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000002')
cosignatory0_key_pair = NemFacade.KeyPair(
    PrivateKey(COSIGNATORY0_PRIVATE_KEY))
print(f'Cosignatory 0 public key: {cosignatory0_key_pair.public_key}')
COSIGNATORY1_PRIVATE_KEY = os.getenv(
    'COSIGNATORY1_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000003')
cosignatory1_key_pair = NemFacade.KeyPair(
    PrivateKey(COSIGNATORY1_PRIVATE_KEY))
print(f'Cosignatory 1 public key: {cosignatory1_key_pair.public_key}')
const MULTISIG_PUBLIC_KEY = process.env.MULTISIG_PUBLIC_KEY || (
    'D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2');
const multisigPublicKey = new PublicKey(MULTISIG_PUBLIC_KEY);
const multisigAddress = facade.network.publicKeyToAddress(
    multisigPublicKey);
console.log(`Multisig public key: ${multisigPublicKey}`);
const COSIGNATORY0_PRIVATE_KEY = process.env.COSIGNATORY0_PRIVATE_KEY || (
    '0000000000000000000000000000000000000000000000000000000000000002');
const cosignatory0KeyPair = new NemFacade.KeyPair(
    new PrivateKey(COSIGNATORY0_PRIVATE_KEY));
console.log(`Cosignatory 0 public key: ${cosignatory0KeyPair.publicKey}`);
const COSIGNATORY1_PRIVATE_KEY = process.env.COSIGNATORY1_PRIVATE_KEY || (
    '0000000000000000000000000000000000000000000000000000000000000003');
const cosignatory1KeyPair = new NemFacade.KeyPair(
    new PrivateKey(COSIGNATORY1_PRIVATE_KEY));
console.log(`Cosignatory 1 public key: ${cosignatory1KeyPair.publicKey}`);

The tutorial requires three separate accounts, configured through environment variables. If not set, default values are used:

Environment Variable Default value Purpose
MULTISIG_PUBLIC_KEY D656..ACF2 2-of-2 multisig account
COSIGNATORY0_PRIVATE_KEY 0000..0002 First cosignatory account, the initiator
COSIGNATORY1_PRIVATE_KEY 0000..0003 Second cosignatory account

Each key is a 64-character hexadecimal string.

Unlike a regular account, the multisig account cannot initiate transactions itself. Instead, its cosignatories sign on its behalf. Its private key is therefore never needed, and its public key is enough to identify the account.

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 of each cosignatory, and the multisig account's address, 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.

Building the Transaction⚓︎

The transaction wrapped inside a multisig transaction is called the inner transaction, and can be any basic transaction, such as the transfer used in this tutorial or the modifications used in Configuring a Multisignature Account. Multisig transactions cannot be nested.

    # Build the inner transfer transaction
    transfer_transaction = facade.transaction_factory.create({
        'type': 'transfer_transaction_v2',
        'signer_public_key': multisig_public_key,
        'timestamp': timestamp.timestamp,
        'deadline': deadline.timestamp,
        'recipient_address': multisig_address,
        'amount': 1_000_000  # 1 XEM
    })
    transfer_transaction.fee = Amount(
        calculate_transaction_fee(transfer_transaction))
    // Build the inner transfer transaction
    const transferTransaction = facade.transactionFactory.create({
        type: 'transfer_transaction_v2',
        signerPublicKey: multisigPublicKey.toString(),
        timestamp: timestamp.timestamp,
        deadline: deadline.timestamp,
        recipientAddress: multisigAddress.toString(),
        amount: 1_000_000n // 1 XEM
    });
    transferTransaction.fee = new models.Amount(
        calculateTransactionFee(transferTransaction));

The inner transfer transaction includes the following fields:

  • : public key of the account whose funds are being transferred, that is, the multisignature account.

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

  • : 1'000'000 atomic units, corresponding to 1 XEM, as explained in the Transfer XEM tutorial.

The inner transaction has its own transaction fee, calculated with . For the 1 XEM sent here, the fee is 0.05 XEM, as shown in the transfer fee schedule.

    # Build the wrapper multisig transaction
    transaction = facade.transaction_factory.create({
        'type': 'multisig_transaction_v1',
        # This is the cosignatory that initiates the transfer
        'signer_public_key': cosignatory0_key_pair.public_key,
        'timestamp': timestamp.timestamp,
        'deadline': deadline.timestamp,
        'inner_transaction':
            facade.transaction_factory.to_non_verifiable_transaction(
                transfer_transaction)
    })
    transaction.fee = Amount(calculate_transaction_fee(transaction))
    // Build the wrapper multisig transaction
    const transaction = facade.transactionFactory.create({
        type: 'multisig_transaction_v1',
        // This is the cosignatory that initiates the transfer
        signerPublicKey: cosignatory0KeyPair.publicKey.toString(),
        timestamp: timestamp.timestamp,
        deadline: deadline.timestamp,
        innerTransaction: facade.transactionFactory.static
            .toNonVerifiableTransaction(transferTransaction)
    });
    transaction.fee = new models.Amount(
        calculateTransactionFee(transaction));

The transfer transaction is then wrapped in a MultisigTransactionV1. Its most relevant fields are:

The multisig wrapper also has its own transaction fee of 0.15 XEM, as shown in the fee schedule. All fees, and the transferred amount, are deducted from the multisig account once the transaction is confirmed.

Initiator: Announcing the Multisig Transaction⚓︎

    # Sign and announce the multisig transaction
    signature = facade.sign_transaction(
        cosignatory0_key_pair, transaction)
    json_payload = facade.transaction_factory.attach_signature(
        transaction, signature)
    print('Built multisig transaction:')
    print(json.dumps(transaction.to_json(), indent=2))
    announce_result = announce_transaction(
        json_payload, 'multisig transaction')
    # The transaction is now waiting for the second signature
    // Sign and announce the multisig transaction
    const signature = facade.signTransaction(
        cosignatory0KeyPair, transaction);
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction, signature);
    console.log('Built multisig transaction:');
    console.log(JSON.stringify(transaction.toJson(), null, 2));
    const announceResult = await announceTransaction(
        jsonPayload, 'multisig transaction');
    // The transaction is now waiting for the second signature

In this case, Cosignatory 0 is the initiator of the multisig transaction. It signs the transaction and announces it to the network.

If valid, the network accepts the transaction, but it is not confirmed yet. Since the multisig account requires two cosignatures and only one has been provided, the transaction waits in the unconfirmed pool until the missing cosignature arrives.

Simpler configurations

In a multisig that requires only one cosignature, such as the 1-of-2 configuration created in the Configuring a Multisignature Account tutorial, the initiating cosignatory's signature is enough. If valid, the transaction is confirmed without any further steps.

Cosignatory: Retrieving the Pending Transaction⚓︎

    # Retrieve the pending transaction from the network
    if 'SUCCESS' == announce_result:
        cosignatory1_address = facade.network.public_key_to_address(
            cosignatory1_key_pair.public_key)
        unconfirmed_path = ('/account/unconfirmedTransactions'
            f'?address={cosignatory1_address}')
        print(f'Fetching pending transactions from {unconfirmed_path}')
        with urllib.request.urlopen(
            f'{NODE_URL}{unconfirmed_path}'
        ) as response:
            pending = json.loads(response.read().decode())['data']
        # Select the pending transaction issued by the multisig account
        inner_transaction_hash = next(
            entry['meta']['data'] for entry in pending
            if entry['transaction'].get('otherTrans', {}).get(
                'signer', '').upper() == str(multisig_public_key))
        print(f'  Inner transaction hash: {inner_transaction_hash}')
    // Retrieve the pending transaction from the network
    if ('SUCCESS' === announceResult) {
        const cosignatory1Address = facade.network.publicKeyToAddress(
            cosignatory1KeyPair.publicKey);
        const unconfirmedPath = '/account/unconfirmedTransactions' +
            `?address=${cosignatory1Address}`;
        console.log('Fetching pending transactions from',
            unconfirmedPath);
        const unconfirmedResponse = await fetch(
            `${NODE_URL}${unconfirmedPath}`);
        const pending = (await unconfirmedResponse.json()).data;
        // Select the pending transaction issued by the multisig account
        const pendingEntry = pending.find(entry =>
            multisigPublicKey.toString() === (entry.transaction
                .otherTrans?.signer ?? '').toUpperCase());
        const innerTransactionHash = pendingEntry.meta.data;
        console.log('  Inner transaction hash:', innerTransactionHash);

At this point, Cosignatory 1 takes over. Cosignatories can use the /account/unconfirmedTransactions GET endpoint to discover pending multisig transactions awaiting their signature.

The metadata of each pending multisig transaction contains the hash of its inner transaction, which is the value that a cosignature must reference.

A cosignatory can have multiple pending multisig transactions awaiting approval. In this example, the code selects the transaction issued by the multisig account. This is sufficient for the tutorial because only one pending transaction is expected from that account.

In real applications, however, this filter is not enough if the multisig account has multiple pending transactions. Instead, inspect the content of each pending transaction, such as its type, recipient, and amount, before selecting the one to cosign.

Verify before cosigning

Always verify the contents of a transaction before cosigning it. Cosignatures are binding and cannot be undone.

Cosignatory: Cosigning the Transaction⚓︎

        # Build the cosignature
        cosignature = facade.transaction_factory.create({
            'type': 'cosignature_v1',
            # This is the cosignatory providing the second signature
            'signer_public_key': cosignatory1_key_pair.public_key,
            'timestamp': timestamp.timestamp,
            'deadline': deadline.timestamp,
            # Hash of the inner transfer transaction
            'other_transaction_hash': inner_transaction_hash,
            # Address of the multisig account
            'multisig_account_address': multisig_address
        })
        cosignature.fee = Amount(calculate_transaction_fee(cosignature))
        // Build the cosignature
        const cosignature = facade.transactionFactory.create({
            type: 'cosignature_v1',
            // This is the cosignatory providing the second signature
            signerPublicKey: cosignatory1KeyPair.publicKey.toString(),
            timestamp: timestamp.timestamp,
            deadline: deadline.timestamp,
            // Hash of the inner transfer transaction
            otherTransactionHash: innerTransactionHash,
            // Address of the multisig account
            multisigAccountAddress: multisigAddress.toString()
        });
        cosignature.fee = new models.Amount(
            calculateTransactionFee(cosignature));

Cosignatory 1 provides the missing signature by announcing a CosignatureV1. The cosignature specifies:

  • : public key of the cosignatory providing the signature.

  • : hash of the inner transfer transaction retrieved in the previous step.

  • : address of the multisig account the signature refers to.

The cosignature has a 0.15 XEM fee. The fee is also deducted from the multisig account once the multisig transaction is confirmed.

        # Sign and announce the cosignature
        cosignature_signature = facade.sign_transaction(
            cosignatory1_key_pair, cosignature)
        cosignature_payload = facade.transaction_factory.attach_signature(
            cosignature, cosignature_signature)
        print('Built cosignature:')
        print(json.dumps(cosignature.to_json(), indent=2))
        cosignature_result = announce_transaction(
            cosignature_payload, 'cosignature')
        // Sign and announce the cosignature
        const cosignatureSignature = facade.signTransaction(
            cosignatory1KeyPair, cosignature);
        const cosignaturePayload = facade.transactionFactory.static
            .attachSignature(cosignature, cosignatureSignature);
        console.log('Built cosignature:');
        console.log(JSON.stringify(cosignature.toJson(), null, 2));
        const cosignatureResult = await announceTransaction(
            cosignaturePayload, 'cosignature');

Cosignatory 1 then signs the cosignature and announces it to the network.

The announced cosignature does not appear in the unconfirmed pool as a separate transaction. Instead, the network attaches it to the pending multisig transaction.

In configurations that require additional cosignatures, the transaction remains pending. The collected signatures can be inspected in the transaction's signatures field by querying /account/unconfirmedTransactions GET again.

In this tutorial, however, the second cosignature completes the transaction, which leaves the pool and is confirmed in the next block.

Waiting for Confirmation⚓︎

        # Wait for the multisig transaction to be confirmed
        if 'SUCCESS' == cosignature_result:
            wait_for_confirmation(
                facade.hash_transaction(transaction),
                'multisig transaction')
        else:
            print(f'Transaction rejected: {cosignature_result}')
        // Wait for the multisig transaction to be confirmed
        if ('SUCCESS' === cosignatureResult) {
            await waitForConfirmation(
                facade.hashTransaction(transaction).toString(),
                'multisig transaction');
        } else {
            console.log('Transaction rejected:', cosignatureResult);
        }

Once all required cosignatures have been collected, the multisig transaction is confirmed as a single unit.

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

Error message Probable cause
FAILURE_TRANSACTION_NOT_ALLOWED_FOR_MULTISIG The multisig account tried to announce the transfer itself.
FAILURE_MULTISIG_NOT_A_COSIGNER The signer of the multisig transaction is not in the cosignatories list.
FAILURE_MULTISIG_NO_MATCHING_MULTISIG The cosignature does not match a pending multisig transaction, or its signer is not a cosignatory.
FAILURE_SIGNATURE_NOT_VERIFIABLE The signature attached to a transaction does not match its .

Output⚓︎

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

Using node http://libertalia.nemtest.net:7890
Multisig public key: D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2
Cosignatory 0 public key: AC1FC0D95CA3255D20C57C179EE6E694A47A725C48DB362CC4978D7745C6A5C3
Cosignatory 1 public key: 26D999AD34795F20D33886047A8CB7DE1ED0042AB7ED1017C602222C8B2A4C23
Fetching current network time from /time-sync/network-time
  Network time: 357569945 s since the nemesis block
Built multisig transaction:
{
  "type": 4100,
  "version": 1,
  "network": 152,
  "timestamp": 357569945,
  "signer_public_key": "AC1FC0D95CA3255D20C57C179EE6E694A47A725C48DB362CC4978D7745C6A5C3",
  "signature": "DD065509918473534035EEFE9258E515861B250BBCF8208A77B12FE6A1AFF42F0C3D51D9A74E7A09F39C29C0CDFF860FF76F597FCE62A28433AFC8D95926A70E",
  "fee": "150000",
  "deadline": 357577145,
  "inner_transaction": {
    "type": 257,
    "version": 2,
    "network": 152,
    "timestamp": 357569945,
    "signer_public_key": "D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2",
    "signature": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
    "fee": "50000",
    "deadline": 357577145,
    "recipient_address": "54424C58494F554F34455035595237344859585333424642474F4E5A42554850334E495332484A36",
    "amount": "1000000",
    "mosaics": []
  },
  "cosignatures": []
}
Announcing multisig transaction to /transaction/announce
  Result: SUCCESS
Fetching pending transactions from /account/unconfirmedTransactions?address=TC7BQFXISQEOPN2PCPPPOM3V4R3XDPNVEHEQLID4
  Inner transaction hash: 24a1079e4dd5063c3a938675653a7525c640824470764cc34ab2a64c0c614c4d
Built cosignature:
{
  "type": 4098,
  "version": 1,
  "network": 152,
  "timestamp": 357569945,
  "signer_public_key": "26D999AD34795F20D33886047A8CB7DE1ED0042AB7ED1017C602222C8B2A4C23",
  "signature": "A094A18F4E63745B4939BB6979BD293856765E25A96B244AC7AB5E7334398802A824E800206DA62888DB474A1C64EBEBB285EC05EB3D08C8F4EA8C40A221A30D",
  "fee": "150000",
  "deadline": 357577145,
  "other_transaction_hash": "24A1079E4DD5063C3A938675653A7525C640824470764CC34AB2A64C0C614C4D",
  "multisig_account_address": "54424C58494F554F34455035595237344859585333424642474F4E5A42554850334E495332484A36"
}
Announcing cosignature to /transaction/announce
  Result: SUCCESS
Waiting for multisig transaction confirmation from /transaction/get?hash=12282A1E794D5D230EC8D0093E925F1BD83BB156F460DC2775045B61A779B8AA
  Transaction status: pending
  Transaction status: pending
  ...
multisig transaction confirmed in block 719496

Key points in the output:

  • Lines 2-4: Public keys of all involved accounts.
  • Line 13 (signer_public_key): Signer of the multisig transaction. Note that it matches Cosignatory 0.
  • Line 22 (signer_public_key): Signer of the inner transfer transaction. Note that it matches the multisig account.
  • Line 35 (Inner transaction hash): Hash of the pending inner transaction, retrieved from the network.
  • Line 42 (signer_public_key): Signer of the cosignature. Note that it matches Cosignatory 1.
  • Line 46 (other_transaction_hash): The inner transaction hash referenced by the cosignature.
  • Line 51: Hash of the multisig transaction, which uniquely identifies it on the network.

The multisig transaction hash shown in the output can be used to look up the confirmed transaction in the NEM testnet explorer.

Conclusion⚓︎

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

In particular, the tutorial showed how to:

Step Related documentation
Wrap transfer in a multisig transaction MultisigTransactionV1,
Sign the multisig transaction
Discover pending transactions /account/unconfirmedTransactions GET
Cosign a pending multisig transaction CosignatureV1