Skip to content

Listening to Multisig Transaction Flow⚓︎

ADVANCED

A transaction from a multisignature account follows a richer lifecycle than a regular transaction. After being announced, it waits in the unconfirmed pool while the network collects the required cosignatures from the account's cosignatories. Only after all cosignatures arrive is the transaction confirmed in a block.

This tutorial recreates the transfer from the Signing a Transaction from a Multisignature Account tutorial, but monitors the full multisig lifecycle using WebSocket channels instead of polling.

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:

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

Cosignatory 0 builds and announces the multisig transaction, while Cosignatory 1 subscribes to the multisig account's WebSocket channels, cosigns, and waits for confirmation.

Alternative: Polling

For a polling-based approach, where the cosignatory discovers the pending transaction by querying the node, see the Signing a Transaction from a Multisignature Account 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, NEM serves WebSockets using the STOMP messaging protocol over SockJS, so a STOMP client and a WebSocket transport are required:

Install the stomper and websockets libraries:

pip install stomper websockets

Install the @stomp/stompjs and sockjs-client libraries:

npm install @stomp/stompjs sockjs-client

See the WebSocket reference for details on the connection protocol.

Full Code⚓︎

import asyncio
import json
import os
import random
import urllib.request
import uuid

import stomper
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
from websockets import connect

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


# SockJS has no Python client library.
# These helpers wrap the raw WebSocket transport to mirror a STOMP client.
def sockjs_url(endpoint_url):
    # SockJS raw WebSocket transport adds a random server and session id
    server = random.randint(100, 999)
    session = uuid.uuid4().hex
    ws_base = endpoint_url.replace('http', 'ws', 1)
    return f'{ws_base}/{server}/{session}/websocket'


async def send_frame(websocket, frame):
    # SockJS wraps each client payload as a JSON array of frame strings
    await websocket.send(json.dumps([frame]))


async def stomp_connect(websocket):
    await websocket.recv()  # consume the SockJS open frame
    await send_frame(
        websocket, stomper.connect('', '', NODE_URL, heartbeats=(0, 0)))


async def stomp_subscribe(websocket, destination, sub_id):
    await send_frame(websocket, stomper.subscribe(destination, sub_id))


async def stomp_send(websocket, destination, body):
    await send_frame(websocket, stomper.send(destination, body))


async def stomp_unsubscribe(websocket, sub_id):
    await send_frame(websocket, stomper.unsubscribe(sub_id))


async def stomp_disconnect(websocket):
    await send_frame(websocket, stomper.disconnect())


def stomp_messages(raw_frame):
    # Yield each STOMP MESSAGE frame in a SockJS data frame
    if 'a' != raw_frame[0]:  # skip 'o' open, 'h' heartbeat, 'c' close
        return
    for payload in json.loads(raw_frame[1:]):
        frame = stomper.unpack_frame(payload)
        if 'MESSAGE' == frame['cmd']:
            yield frame


async def stomp_frames(websocket):
    # Yield each STOMP MESSAGE frame as it arrives
    async for raw_frame in websocket:
        for frame in stomp_messages(raw_frame):
            yield frame


facade = NemFacade('testnet')
# Set up the multisig and cosignatory accounts
MULTISIG_PUBLIC_KEY = os.getenv(
    'MULTISIG_PUBLIC_KEY',
    'D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2')
multisig_public_key = PublicKey(MULTISIG_PUBLIC_KEY)
multisig_address = str(facade.network.public_key_to_address(
    multisig_public_key))
print(f'Multisig address: {multisig_address}')
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}')



async def main():
    # [Cosignatory 0] Build and sign the multisig transaction
    with urllib.request.urlopen(
        f'{NODE_URL}/time-sync/network-time'
    ) as resp:
        network_time = json.loads(
            resp.read().decode())['receiveTimeStamp'] // 1000
    timestamp = NetworkTimestamp(network_time)
    deadline = timestamp.add_hours(2)

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

    transaction = facade.transaction_factory.create({
        'type': 'multisig_transaction_v1',
        '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))

    signature = facade.sign_transaction(
        cosignatory0_key_pair, transaction)
    json_payload = facade.transaction_factory.attach_signature(
        transaction, signature)
    transaction_hash = str(facade.hash_transaction(transaction)).upper()
    print('[Cosignatory 0] Built multisig transaction '
        f'{transaction_hash[:16]}...')

    # [Cosignatory 1] Connect to the WebSocket
    endpoint = f'{WS_URL}/w/messages'
    async with connect(sockjs_url(endpoint)) as websocket:
        await stomp_connect(websocket)
        print(f'[Cosignatory 1] Connected to {WS_URL}')
        frames = stomp_frames(websocket)

        # [Cosignatory 1] Subscribe to the multisig account channels
        account_channel = f'/account/{multisig_address}'
        channels = {
            account_channel: 'id-0',
            f'/unconfirmed/{multisig_address}': 'id-1',
            f'/transactions/{multisig_address}': 'id-2',
        }
        for channel, sub_id in channels.items():
            await stomp_subscribe(websocket, channel, sub_id)
            print(f'[Cosignatory 1] Subscribed to {channel} channel')

        # [Cosignatory 1] Register the multisig account
        await stomp_send(websocket, '/w/api/account/get',
            json.dumps({'account': multisig_address}))
        async for frame in frames:
            if account_channel == frame['headers']['destination']:
                balance = json.loads(
                    frame['body'])['account']['balance']
                print(f'Account update: balance={balance}')
                break
        print('[Cosignatory 1] Multisig account registered')

        # [Cosignatory 0] Announce the multisig transaction
        print('[Cosignatory 0] Announcing multisig transaction '
            f'{transaction_hash[:16]}...')
        announce_request = urllib.request.Request(
            f'{NODE_URL}/transaction/announce',
            data=json_payload.encode(),
            headers={'Content-Type': 'application/json'},
            method='POST'
        )
        with urllib.request.urlopen(announce_request) as resp:
            result = json.loads(resp.read().decode())
        if 'SUCCESS' != result['message']:
            print(f'Transaction rejected: {result["message"]}')
            return
        # The transaction is now waiting for the second signature

        # [Cosignatory 1] Select the pending multisig transaction
        inner_transaction_hash = None
        async for frame in frames:
            destination = frame['headers']['destination']
            body = json.loads(frame['body'])
            if '/unconfirmed/' not in destination:
                continue
            signer = body['transaction'].get(
                'otherTrans', {}).get('signer', '')
            if signer.upper() != str(multisig_public_key):
                continue
            inner_transaction_hash = body['meta']['innerHash']['data']
            print('unconfirmed: innerHash='
                f'{inner_transaction_hash[:16]}...')

            # [Cosignatory 1] Cosign the pending transaction
            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))
            cosignature_signature = facade.sign_transaction(
                cosignatory1_key_pair, cosignature)
            cosignature_payload = (
                facade.transaction_factory.attach_signature(
                    cosignature, cosignature_signature))
            cosignature_request = urllib.request.Request(
                f'{NODE_URL}/transaction/announce',
                data=cosignature_payload.encode(),
                headers={'Content-Type': 'application/json'},
                method='POST'
            )
            with urllib.request.urlopen(cosignature_request) as resp:
                cosignature_result = json.loads(resp.read().decode())
            if 'SUCCESS' != cosignature_result['message']:
                print('Cosignature rejected: '
                    f'{cosignature_result["message"]}')
                return
            print('[Cosignatory 1] Announced cosignature')
            break

        # [Cosignatory 1] Wait for confirmation
        confirmed = False
        async for frame in frames:
            destination = frame['headers']['destination']
            body = json.loads(frame['body'])
            if account_channel == destination:
                balance = body['account']['balance']
                print(f'Account update: balance={balance}')
                if confirmed:
                    break
            elif '/transactions/' in destination:
                message_hash = body['meta']['innerHash']['data']
                print(f'confirmed: innerHash={message_hash[:16]}...')
                matched = message_hash == inner_transaction_hash
                if matched and not confirmed:
                    print('Multisig transaction confirmed')
                    confirmed = True

        # [Cosignatory 1] Unsubscribe before closing
        for sub_id in channels.values():
            await stomp_unsubscribe(websocket, sub_id)
        print('[Cosignatory 1] Unsubscribed from all channels')
        await stomp_disconnect(websocket)


try:
    asyncio.run(main())
except Exception as error:
    print(error)

Download source

import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';
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';
const WS_URL = NODE_URL.replace(':7890', ':7778');
console.log(`Using node ${NODE_URL}`);

const facade = new NemFacade('testnet');
// Set up the multisig and cosignatory accounts
const MULTISIG_PUBLIC_KEY = process.env.MULTISIG_PUBLIC_KEY || (
    'D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2');
const multisigPublicKey = new PublicKey(MULTISIG_PUBLIC_KEY);
const multisigAddress = facade.network.publicKeyToAddress(
    multisigPublicKey).toString();
console.log(`Multisig address: ${multisigAddress}`);
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 {
    // [Cosignatory 0] Build and sign the multisig transaction
    const timeResponse = await fetch(
        `${NODE_URL}/time-sync/network-time`);
    const networkTime = Math.floor(
        (await timeResponse.json()).receiveTimeStamp / 1000);
    const timestamp = new NetworkTimestamp(networkTime);
    const deadline = timestamp.addHours(2);

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

    const transaction = facade.transactionFactory.create({
        type: 'multisig_transaction_v1',
        signerPublicKey: cosignatory0KeyPair.publicKey.toString(),
        timestamp: timestamp.timestamp,
        deadline: deadline.timestamp,
        innerTransaction: facade.transactionFactory.static
            .toNonVerifiableTransaction(transferTransaction)
    });
    transaction.fee = new models.Amount(
        calculateTransactionFee(transaction));

    const signature = facade.signTransaction(
        cosignatory0KeyPair, transaction);
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction, signature);
    const transactionHash =
        facade.hashTransaction(transaction).toString().toUpperCase();
    const shortHash = transactionHash.substring(0, 16);
    console.log(
        `[Cosignatory 0] Built multisig transaction ${shortHash}...`);

    // [Cosignatory 1] Connect to the WebSocket
    const client = new Client({
        webSocketFactory: () => new SockJS(`${WS_URL}/w/messages`)
    });
    await new Promise(resolve => {
        client.onConnect = resolve;
        client.activate();
    });
    console.log(`[Cosignatory 1] Connected to ${WS_URL}`);

    // [Cosignatory 1] Select the pending multisig transaction
    let innerTransactionHash = null;
    let resolveCosigned;
    const cosigned = new Promise(resolve => {
        resolveCosigned = resolve;
    });
    const onUnconfirmed = async message => {
        if (null !== innerTransactionHash)
            return;
        const body = JSON.parse(message.body);
        const signer = (body.transaction.otherTrans?.signer ?? '')
            .toUpperCase();
        if (multisigPublicKey.toString() !== signer)
            return;
        innerTransactionHash = body.meta.innerHash.data;
        console.log(
            'unconfirmed: innerHash=' +
            `${innerTransactionHash.substring(0, 16)}...`);

        // [Cosignatory 1] Cosign the pending transaction
        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
        });
        cosignature.fee = new models.Amount(
            calculateTransactionFee(cosignature));
        const cosignatureSignature = facade.signTransaction(
            cosignatory1KeyPair, cosignature);
        const cosignaturePayload = facade.transactionFactory.static
            .attachSignature(cosignature, cosignatureSignature);
        const cosignatureResponse = await fetch(
            `${NODE_URL}/transaction/announce`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: cosignaturePayload
            });
        const cosignatureResult = await cosignatureResponse.json();
        if ('SUCCESS' !== cosignatureResult.message) {
            console.log(
                `Cosignature rejected: ${cosignatureResult.message}`);
            resolveCosigned(false);
            return;
        }
        console.log('[Cosignatory 1] Announced cosignature');
        resolveCosigned(true);

    };
    // [Cosignatory 1] Wait for confirmation
    let confirmed = false;
    let resolveRegistered;
    let resolveDone;
    const registered = new Promise(resolve => {
        resolveRegistered = resolve;
    });
    const done = new Promise(resolve => {
        resolveDone = resolve;
    });
    const onConfirmed = message => {
        const messageHash = JSON.parse(message.body).meta.innerHash.data;
        console.log(
            `confirmed: innerHash=${messageHash.substring(0, 16)}...`);
        if (messageHash === innerTransactionHash && !confirmed) {
            console.log('Multisig transaction confirmed');
            confirmed = true;
        }
    };
    const onAccountUpdate = message => {
        const { balance } = JSON.parse(message.body).account;
        console.log(`Account update: balance=${balance}`);
        resolveRegistered();
        if (confirmed)
            resolveDone();
    };

    // [Cosignatory 1] Subscribe to the multisig account channels
    const accountChannel = `/account/${multisigAddress}`;
    const subscriptions = [
        { channel: accountChannel, handler: onAccountUpdate, id: 'id-0' },
        {
            channel: `/unconfirmed/${multisigAddress}`,
            handler: onUnconfirmed,
            id: 'id-1'
        },
        {
            channel: `/transactions/${multisigAddress}`,
            handler: onConfirmed,
            id: 'id-2'
        }
    ];
    for (const { channel, handler, id } of subscriptions) {
        client.subscribe(channel, handler, { id });
        console.log(`[Cosignatory 1] Subscribed to ${channel} channel`);
    }

    // [Cosignatory 1] Register the multisig account
    client.publish({
        destination: '/w/api/account/get',
        body: JSON.stringify({ account: multisigAddress })
    });
    await registered;
    console.log('[Cosignatory 1] Multisig account registered');

    // [Cosignatory 0] Announce the multisig transaction
    console.log(
        '[Cosignatory 0] Announcing multisig transaction ' +
        `${shortHash}...`);
    const response = await fetch(`${NODE_URL}/transaction/announce`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: jsonPayload
    });
    const announceResult = await response.json();
    if ('SUCCESS' === announceResult.message) {
        // The transaction is now waiting for the second signature

        // Wait for the cosignature to be announced and the
        // transaction to confirm
        if (await cosigned)
            await done;
    } else {
        console.log(`Transaction rejected: ${announceResult.message}`);
    }
    // [Cosignatory 1] Unsubscribe before closing
    for (const { id } of subscriptions)
        client.unsubscribe(id);
    console.log('[Cosignatory 1] Unsubscribed from all channels');
    client.deactivate();
} catch (error) {
    console.error(error);
}

Download source

The snippet uses the NODE_URL environment variable to set the NEM node. If no value is provided, a default one is used.

WS_URL defines the WebSocket endpoint for the same node. It is derived from NODE_URL by replacing port 7890, the default HTTP API port, with 7778, the default NIS WebSocket port.

Python SockJS helpers

There is no SockJS client library for Python, so a few small helper methods are defined at the top of the file for convenience.

Code Explanation⚓︎

A multisig transaction involves two distinct roles: an initiator (Cosignatory 0) that builds, signs, and announces the multisig transaction, and one or more cosignatories (Cosignatory 1 in this tutorial) that monitor WebSocket channels and cosign after verifying the transaction.

In practice, each role runs as a separate program on a separate machine, holding only its own private key. This tutorial combines both roles in a single script for simplicity.

Setting Up the Accounts⚓︎

# Set up the multisig and cosignatory accounts
MULTISIG_PUBLIC_KEY = os.getenv(
    'MULTISIG_PUBLIC_KEY',
    'D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2')
multisig_public_key = PublicKey(MULTISIG_PUBLIC_KEY)
multisig_address = str(facade.network.public_key_to_address(
    multisig_public_key))
print(f'Multisig address: {multisig_address}')
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}')
// Set up the multisig and cosignatory accounts
const MULTISIG_PUBLIC_KEY = process.env.MULTISIG_PUBLIC_KEY || (
    'D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2');
const multisigPublicKey = new PublicKey(MULTISIG_PUBLIC_KEY);
const multisigAddress = facade.network.publicKeyToAddress(
    multisigPublicKey).toString();
console.log(`Multisig address: ${multisigAddress}`);
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. The WebSocket channels subscribed later are scoped to this address.

Initiator: Building the Multisig Transaction⚓︎

    # [Cosignatory 0] Build and sign the multisig transaction
    with urllib.request.urlopen(
        f'{NODE_URL}/time-sync/network-time'
    ) as resp:
        network_time = json.loads(
            resp.read().decode())['receiveTimeStamp'] // 1000
    timestamp = NetworkTimestamp(network_time)
    deadline = timestamp.add_hours(2)

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

    transaction = facade.transaction_factory.create({
        'type': 'multisig_transaction_v1',
        '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))

    signature = facade.sign_transaction(
        cosignatory0_key_pair, transaction)
    json_payload = facade.transaction_factory.attach_signature(
        transaction, signature)
    transaction_hash = str(facade.hash_transaction(transaction)).upper()
    print('[Cosignatory 0] Built multisig transaction '
        f'{transaction_hash[:16]}...')
    // [Cosignatory 0] Build and sign the multisig transaction
    const timeResponse = await fetch(
        `${NODE_URL}/time-sync/network-time`);
    const networkTime = Math.floor(
        (await timeResponse.json()).receiveTimeStamp / 1000);
    const timestamp = new NetworkTimestamp(networkTime);
    const deadline = timestamp.addHours(2);

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

    const transaction = facade.transactionFactory.create({
        type: 'multisig_transaction_v1',
        signerPublicKey: cosignatory0KeyPair.publicKey.toString(),
        timestamp: timestamp.timestamp,
        deadline: deadline.timestamp,
        innerTransaction: facade.transactionFactory.static
            .toNonVerifiableTransaction(transferTransaction)
    });
    transaction.fee = new models.Amount(
        calculateTransactionFee(transaction));

    const signature = facade.signTransaction(
        cosignatory0KeyPair, transaction);
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction, signature);
    const transactionHash =
        facade.hashTransaction(transaction).toString().toUpperCase();
    const shortHash = transactionHash.substring(0, 16);
    console.log(
        `[Cosignatory 0] Built multisig transaction ${shortHash}...`);

Cosignatory 0 fetches the network time, builds an inner transfer of 1 XEM from the multisig account to itself, wraps it in a MultisigTransactionV1, and signs it. The implementation follows the same pattern described in the Signing a Transaction from a Multisignature Account tutorial.

The transaction is prepared, but it is not announced yet. The announcement happens after the channel subscriptions are established, ensuring that the resulting notifications are not missed.

Cosignatory: Connecting to the WebSocket⚓︎

    # [Cosignatory 1] Connect to the WebSocket
    endpoint = f'{WS_URL}/w/messages'
    async with connect(sockjs_url(endpoint)) as websocket:
        await stomp_connect(websocket)
        print(f'[Cosignatory 1] Connected to {WS_URL}')
        frames = stomp_frames(websocket)
    // [Cosignatory 1] Connect to the WebSocket
    const client = new Client({
        webSocketFactory: () => new SockJS(`${WS_URL}/w/messages`)
    });
    await new Promise(resolve => {
        client.onConnect = resolve;
        client.activate();
    });
    console.log(`[Cosignatory 1] Connected to ${WS_URL}`);

Cosignatory 1 opens a SockJS connection to the /w/messages endpoint on WS_URL and starts a STOMP session over it.

Cosignatory: Subscribing to the Channels⚓︎

        # [Cosignatory 1] Subscribe to the multisig account channels
        account_channel = f'/account/{multisig_address}'
        channels = {
            account_channel: 'id-0',
            f'/unconfirmed/{multisig_address}': 'id-1',
            f'/transactions/{multisig_address}': 'id-2',
        }
        for channel, sub_id in channels.items():
            await stomp_subscribe(websocket, channel, sub_id)
            print(f'[Cosignatory 1] Subscribed to {channel} channel')
    // [Cosignatory 1] Subscribe to the multisig account channels
    const accountChannel = `/account/${multisigAddress}`;
    const subscriptions = [
        { channel: accountChannel, handler: onAccountUpdate, id: 'id-0' },
        {
            channel: `/unconfirmed/${multisigAddress}`,
            handler: onUnconfirmed,
            id: 'id-1'
        },
        {
            channel: `/transactions/${multisigAddress}`,
            handler: onConfirmed,
            id: 'id-2'
        }
    ];
    for (const { channel, handler, id } of subscriptions) {
        client.subscribe(channel, handler, { id });
        console.log(`[Cosignatory 1] Subscribed to ${channel} channel`);
    }

Cosignatory 1 subscribes to the same three address-scoped channels used in the Listening to Transaction Flow tutorial:

The subscriptions use the IDs id-0, id-1 and id-2, which identify them when the code unsubscribes at the end.

The difference is the address that each channel is scoped to. For a pending multisig transaction, the node sends notifications to the initiating cosignatory and to the accounts involved in the inner transaction.

In this example, the notified accounts are Cosignatory 0, as the initiator, and the multisig account, which is both the sender and the recipient of the inner transfer. Other cosignatories, such as Cosignatory 1, do not receive notifications.

As a result, a cosignatory that is waiting to approve transactions must subscribe to the multisig account's address, not the cosignatory's own address.

Message handling differences

In JavaScript, each channel is subscribed with a dedicated handler function, defined in the cosigning and confirmation steps below. In Python, messages are instead read sequentially from the connection as they arrive.

All three channels stay silent until the address is registered, which the next step performs.

Cosignatory: Registering the Multisig Account⚓︎

        # [Cosignatory 1] Register the multisig account
        await stomp_send(websocket, '/w/api/account/get',
            json.dumps({'account': multisig_address}))
        async for frame in frames:
            if account_channel == frame['headers']['destination']:
                balance = json.loads(
                    frame['body'])['account']['balance']
                print(f'Account update: balance={balance}')
                break
        print('[Cosignatory 1] Multisig account registered')
    // [Cosignatory 1] Register the multisig account
    client.publish({
        destination: '/w/api/account/get',
        body: JSON.stringify({ account: multisigAddress })
    });
    await registered;
    console.log('[Cosignatory 1] Multisig account registered');

To receive notifications on an account's channels, the address must first be registered with the node.

The code sends a request to w/api/account/get REQ, which registers the multisig address and also forces the node to send the account's current state on the account/{address} WS channel.

The code waits for this first account notification, which confirms that the registration is active. The notification follows the AccountMetaDataPair schema.

Initiator: Announcing the Multisig Transaction⚓︎

        # [Cosignatory 0] Announce the multisig transaction
        print('[Cosignatory 0] Announcing multisig transaction '
            f'{transaction_hash[:16]}...')
        announce_request = urllib.request.Request(
            f'{NODE_URL}/transaction/announce',
            data=json_payload.encode(),
            headers={'Content-Type': 'application/json'},
            method='POST'
        )
        with urllib.request.urlopen(announce_request) as resp:
            result = json.loads(resp.read().decode())
        if 'SUCCESS' != result['message']:
            print(f'Transaction rejected: {result["message"]}')
            return
        # The transaction is now waiting for the second signature
    // [Cosignatory 0] Announce the multisig transaction
    console.log(
        '[Cosignatory 0] Announcing multisig transaction ' +
        `${shortHash}...`);
    const response = await fetch(`${NODE_URL}/transaction/announce`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: jsonPayload
    });
    const announceResult = await response.json();
    if ('SUCCESS' === announceResult.message) {
        // The transaction is now waiting for the second signature

Announce after subscribing to channels

Always announce the transaction after subscribing to the WebSocket channels to ensure the listener is ready. Otherwise, notifications could arrive before the WebSocket is listening.

A cosignatory that misses the notification, for example by subscribing only after the announcement, can still discover the pending transaction by polling /account/unconfirmedTransactions GET.

Once Cosignatory 1 is subscribed, Cosignatory 0 announces the multisig transaction to the /transaction/announce POST endpoint and checks the result. If the node rejects it, the code prints the rejection reason and stops.

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.

Cosignatory: Cosigning the Pending Transaction⚓︎

        # [Cosignatory 1] Select the pending multisig transaction
        inner_transaction_hash = None
        async for frame in frames:
            destination = frame['headers']['destination']
            body = json.loads(frame['body'])
            if '/unconfirmed/' not in destination:
                continue
            signer = body['transaction'].get(
                'otherTrans', {}).get('signer', '')
            if signer.upper() != str(multisig_public_key):
                continue
            inner_transaction_hash = body['meta']['innerHash']['data']
            print('unconfirmed: innerHash='
                f'{inner_transaction_hash[:16]}...')
    // [Cosignatory 1] Select the pending multisig transaction
    let innerTransactionHash = null;
    let resolveCosigned;
    const cosigned = new Promise(resolve => {
        resolveCosigned = resolve;
    });
    const onUnconfirmed = async message => {
        if (null !== innerTransactionHash)
            return;
        const body = JSON.parse(message.body);
        const signer = (body.transaction.otherTrans?.signer ?? '')
            .toUpperCase();
        if (multisigPublicKey.toString() !== signer)
            return;
        innerTransactionHash = body.meta.innerHash.data;
        console.log(
            'unconfirmed: innerHash=' +
            `${innerTransactionHash.substring(0, 16)}...`);

The pending multisig transaction arrives on the unconfirmed/{address} WS channel as a TransactionMetaDataPair. For multisig transactions, the meta field contains an additional innerHash field, holding the hash of the 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 enough 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. The full multisig transaction is available in the notification's transaction field for inspection.

            # [Cosignatory 1] Cosign the pending transaction
            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))
            cosignature_signature = facade.sign_transaction(
                cosignatory1_key_pair, cosignature)
            cosignature_payload = (
                facade.transaction_factory.attach_signature(
                    cosignature, cosignature_signature))
            cosignature_request = urllib.request.Request(
                f'{NODE_URL}/transaction/announce',
                data=cosignature_payload.encode(),
                headers={'Content-Type': 'application/json'},
                method='POST'
            )
            with urllib.request.urlopen(cosignature_request) as resp:
                cosignature_result = json.loads(resp.read().decode())
            if 'SUCCESS' != cosignature_result['message']:
                print('Cosignature rejected: '
                    f'{cosignature_result["message"]}')
                return
            print('[Cosignatory 1] Announced cosignature')
            break
        // [Cosignatory 1] Cosign the pending transaction
        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
        });
        cosignature.fee = new models.Amount(
            calculateTransactionFee(cosignature));
        const cosignatureSignature = facade.signTransaction(
            cosignatory1KeyPair, cosignature);
        const cosignaturePayload = facade.transactionFactory.static
            .attachSignature(cosignature, cosignatureSignature);
        const cosignatureResponse = await fetch(
            `${NODE_URL}/transaction/announce`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: cosignaturePayload
            });
        const cosignatureResult = await cosignatureResponse.json();
        if ('SUCCESS' !== cosignatureResult.message) {
            console.log(
                `Cosignature rejected: ${cosignatureResult.message}`);
            resolveCosigned(false);
            return;
        }
        console.log('[Cosignatory 1] Announced cosignature');
        resolveCosigned(true);

The code then builds a CosignatureV1 referencing the inner transaction hash and the multisig account address, signs it with Cosignatory 1's key, and announces it using the /transaction/announce POST endpoint.

Cosignatory: Waiting for Confirmation⚓︎

        # [Cosignatory 1] Wait for confirmation
        confirmed = False
        async for frame in frames:
            destination = frame['headers']['destination']
            body = json.loads(frame['body'])
            if account_channel == destination:
                balance = body['account']['balance']
                print(f'Account update: balance={balance}')
                if confirmed:
                    break
            elif '/transactions/' in destination:
                message_hash = body['meta']['innerHash']['data']
                print(f'confirmed: innerHash={message_hash[:16]}...')
                matched = message_hash == inner_transaction_hash
                if matched and not confirmed:
                    print('Multisig transaction confirmed')
                    confirmed = True
    // [Cosignatory 1] Wait for confirmation
    let confirmed = false;
    let resolveRegistered;
    let resolveDone;
    const registered = new Promise(resolve => {
        resolveRegistered = resolve;
    });
    const done = new Promise(resolve => {
        resolveDone = resolve;
    });
    const onConfirmed = message => {
        const messageHash = JSON.parse(message.body).meta.innerHash.data;
        console.log(
            `confirmed: innerHash=${messageHash.substring(0, 16)}...`);
        if (messageHash === innerTransactionHash && !confirmed) {
            console.log('Multisig transaction confirmed');
            confirmed = true;
        }
    };
    const onAccountUpdate = message => {
        const { balance } = JSON.parse(message.body).account;
        console.log(`Account update: balance=${balance}`);
        resolveRegistered();
        if (confirmed)
            resolveDone();
    };

The announced cosignature does not appear in the unconfirmed pool as a separate transaction, so it does not trigger a notification of its own. Instead, the network attaches it to the pending multisig transaction, which triggers a new notification on the unconfirmed/{address} WS channel. Since this update only reflects the addition of a cosignature, the code ignores it.

If the multisig transaction requires additional cosignatures, it remains in the unconfirmed pool until all required cosignatures have been collected. In this tutorial, the second cosignature satisfies the multisig requirements, so the transaction leaves the unconfirmed pool and, if valid, is confirmed in the next block.

The confirmation arrives on the transactions/{address} WS channel. Since both the sender and the recipient of the inner transfer are the multisig account, this notification is delivered twice, once for each role. The code prints both notifications, but reports the confirmation only once.

The block that includes the transaction also triggers a final notification on the account/{address} WS channel with the account's updated state. Once this final notification arrives, the program moves on to the cleanup step.

Cosignatory: Unsubscribing from Channels⚓︎

        # [Cosignatory 1] Unsubscribe before closing
        for sub_id in channels.values():
            await stomp_unsubscribe(websocket, sub_id)
        print('[Cosignatory 1] Unsubscribed from all channels')
        await stomp_disconnect(websocket)
    // [Cosignatory 1] Unsubscribe before closing
    for (const { id } of subscriptions)
        client.unsubscribe(id);
    console.log('[Cosignatory 1] Unsubscribed from all channels');
    client.deactivate();

After confirmation, Cosignatory 1 unsubscribes from the three channels and ends the STOMP session before the connection closes.

Output⚓︎

Using node http://libertalia.nemtest.net:7890
Multisig address: TBLXIOUO4EP5YR74HYXS3BFBGONZBUHP3NIS2HJ6
Cosignatory 0 public key: AC1FC0D95CA3255D20C57C179EE6E694A47A725C48DB362CC4978D7745C6A5C3
Cosignatory 1 public key: 26D999AD34795F20D33886047A8CB7DE1ED0042AB7ED1017C602222C8B2A4C23
[Cosignatory 0] Built multisig transaction 844BBBB420167B0D...
[Cosignatory 1] Connected to http://libertalia.nemtest.net:7778
[Cosignatory 1] Subscribed to /account/TBLXIOUO4EP5YR74HYXS3BFBGONZBUHP3NIS2HJ6 channel
[Cosignatory 1] Subscribed to /unconfirmed/TBLXIOUO4EP5YR74HYXS3BFBGONZBUHP3NIS2HJ6 channel
[Cosignatory 1] Subscribed to /transactions/TBLXIOUO4EP5YR74HYXS3BFBGONZBUHP3NIS2HJ6 channel
Account update: balance=9959750000
[Cosignatory 1] Multisig account registered
[Cosignatory 0] Announcing multisig transaction 844BBBB420167B0D...
unconfirmed: innerHash=3e1fba4d39d9f053...
[Cosignatory 1] Announced cosignature
confirmed: innerHash=3e1fba4d39d9f053...
Multisig transaction confirmed
confirmed: innerHash=3e1fba4d39d9f053...
Account update: balance=9959400000
[Cosignatory 1] Unsubscribed from all channels

The output shows:

  • Accounts (lines 2-4): The multisig account address and the public keys of both cosignatories.
  • Build (line 5): Cosignatory 0 builds and signs the multisig transaction.
  • Connection (line 6): The STOMP session is established over the node's WebSocket endpoint at port 7778.
  • Subscriptions (lines 7-9): The three channels, all scoped to the multisig account's address, are subscribed.
  • Registration (lines 11): The multisig account's current state arrives on the account channel, confirming the registration.
  • Announcement (line 12): Cosignatory 0 announces the multisig transaction.
  • Cosigning (lines 13-14): The pending multisig transaction arrives on the unconfirmed channel with its inner transaction hash, and Cosignatory 1 announces the cosignature.
  • Confirmation (lines 15-17): The completed transaction is confirmed in a block. The notification arrives twice because the inner transfer's sender and recipient are both the multisig account.
  • Account update (line 18): The block containing the transaction triggers a final account notification. The balance is reduced by the 0.35 XEM in fees, since the transferred 1 XEM returns to the sender.
  • Unsubscribe (line 19): The code unsubscribes from the three channels.

Conclusion⚓︎

This tutorial showed how to:

Step Related documentation
Subscribe to the multisig account's channels unconfirmed/{address} WS
transactions/{address} WS
Register the multisig account w/api/account/get REQ
Handle pending multisig messages TransactionMetaDataPair
Cosign on an unconfirmed notification CosignatureV1