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 the transfer:

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:

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

This tutorial uses the Tyrus WebSocket client, which is loaded by the JBang dependency line in the snippet.

See the WebSocket reference for details on the connection protocol.

Full Code⚓︎

The following is the complete code listing for this tutorial. A detailed, step-by-step explanation follows in the next section.

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


def announce_transaction(payload, endpoint, label):
    request = urllib.request.Request(
        f'{NODE_URL}{endpoint}',
        data=payload.encode(),
        headers={'Content-Type': 'application/json'},
        method='POST'
    )
    with urllib.request.urlopen(request) as response:
        result = json.loads(response.read().decode())
    if 'SUCCESS' == result['message']:
        print(label)
    return result['message']


# 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
    transfer_transaction = facade.create_transaction_from_descriptor({
        'type': 'transfer_transaction_v2',
        'recipient_address': multisig_address,
        'amount': 1_000_000  # 1 XEM
    }, multisig_public_key, 0, 2 * 60 * 60)
    transfer_transaction.fee = Amount(
        calculate_transaction_fee(transfer_transaction))

    transaction = facade.create_transaction_from_descriptor({
        'type': 'multisig_transaction_v1',
        'inner_transaction':
            facade.transaction_factory.to_non_verifiable_transaction(
                transfer_transaction)
    }, cosignatory0_key_pair.public_key, 0, 2 * 60 * 60)
    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
        channels = {
            f'/account/{multisig_address}': '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/' in 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
        announce_result = announce_transaction(
            json_payload, '/transaction/announce',
            '[Cosignatory 0] Announcing multisig transaction '
            f'{transaction_hash[:16]}...')
        if 'SUCCESS' != announce_result:
            print(f'Transaction rejected: {announce_result}')
            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.create_transaction_from_descriptor({
                'type': 'cosignature_v1',
                # Hash of the inner transfer transaction
                'other_transaction_hash': inner_transaction_hash,
                # Address of the multisig account
                'multisig_account_address': multisig_address
            }, cosignatory1_key_pair.public_key, 0, 2 * 60 * 60)
            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_result = announce_transaction(
                cosignature_payload, '/transaction/announce',
                '[Cosignatory 1] Announced cosignature')
            if 'SUCCESS' != cosignature_result:
                print(f'Cosignature rejected: {cosignature_result}')
                return
            break

        # [Cosignatory 1] Wait for confirmation
        confirmed = False
        async for frame in frames:
            destination = frame['headers']['destination']
            body = json.loads(frame['body'])
            if '/account/' in 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 { Hash256, PrivateKey, PublicKey } from 'symbol-sdk';
import {
    Address, NemFacade, calculateTransactionFee, descriptors, 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}`);

async function announceTransaction(payload, endpoint, label) {
    const response = await fetch(`${NODE_URL}${endpoint}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: payload
    });
    if (!response.ok)
        throw new Error(`HTTP ${response.status}`);
    const result = await response.json();
    if ('SUCCESS' === result.message)
        console.log(label);
    return result.message;
}

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 transferTransaction = facade.createTransactionFromTypedDescriptor(
        new descriptors.TransferTransactionV2Descriptor(
            new Address(multisigAddress),
            new models.Amount(1_000_000n)), // 1 XEM
        multisigPublicKey,
        0n,
        2 * 60 * 60);
    transferTransaction.fee = new models.Amount(
        calculateTransactionFee(transferTransaction));

    const transaction = facade.createTransactionFromTypedDescriptor(
        new descriptors.MultisigTransactionV1Descriptor(
            facade.transactionFactory.static.toNonVerifiableTransaction(
                transferTransaction)),
        cosignatory0KeyPair.publicKey,
        0n,
        2 * 60 * 60);
    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.createTransactionFromTypedDescriptor(
            new descriptors.CosignatureV1Descriptor(
                // Hash of the inner transfer transaction
                new Hash256(innerTransactionHash),
                // Address of the multisig account
                new Address(multisigAddress)),
            // This is the cosignatory providing the second signature
            cosignatory1KeyPair.publicKey,
            0n,
            2 * 60 * 60);
        cosignature.fee = new models.Amount(
            calculateTransactionFee(cosignature));
        const cosignatureSignature = facade.signTransaction(
            cosignatory1KeyPair, cosignature);
        const cosignaturePayload = facade.transactionFactory.static
            .attachSignature(cosignature, cosignatureSignature);
        const cosignatureResult = await announceTransaction(
            cosignaturePayload, '/transaction/announce',
            '[Cosignatory 1] Announced cosignature');
        if ('SUCCESS' !== cosignatureResult) {
            console.log(
                `Cosignature rejected: ${cosignatureResult}`);
            resolveCosigned(false);
            return;
        }
        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 subscriptions = [
        {
            channel: `/account/${multisigAddress}`,
            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
    const announceResult = await announceTransaction(
        jsonPayload, '/transaction/announce',
        '[Cosignatory 0] Announcing multisig transaction ' +
        `${shortHash}...`);
    if ('SUCCESS' === announceResult) {
        // 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}`);
    }
    // [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

//JAVA 21+
//DEPS org.symbol:symbol-sdk:3.3.3
//DEPS org.glassfish.tyrus.bundles:tyrus-standalone-client:2.2.0

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadLocalRandom;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import jakarta.websocket.ClientEndpoint;
import jakarta.websocket.ContainerProvider;
import jakarta.websocket.OnMessage;
import jakarta.websocket.RemoteEndpoint;
import jakarta.websocket.Session;

import org.symbol.sdk.CryptoTypes;
import org.symbol.sdk.facade.NemFacade;
import org.symbol.sdk.nem.Address;
import org.symbol.sdk.nem.FeeCalculator;
import org.symbol.sdk.nem.KeyPair;
import org.symbol.sdk.nem.NemTransactionFactory;
import org.symbol.sdk.nem.descriptors.CosignatureV1Descriptor;
import org.symbol.sdk.nem.descriptors.MultisigTransactionV1Descriptor;
import org.symbol.sdk.nem.descriptors.TransferTransactionV2Descriptor;
import org.symbol.sdk.nem.models.Amount;
import org.symbol.sdk.nem.models.Transaction;

@ClientEndpoint
public final class ListenMultisigTransactionFlow {
    private static final ObjectMapper JSON_MAPPER = new ObjectMapper();

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

    private final BlockingQueue<String> rawFrames =
        new LinkedBlockingQueue<>();

    private final String nodeUrl = System.getenv().getOrDefault(
        "NODE_URL", "http://libertalia.nemtest.net:7890");

    private final String wsUrl = nodeUrl.replace(":7890", ":7778");

    private final NemFacade facade = new NemFacade("testnet");

    private String announceTransaction(
        final String payload,
        final String endpoint,
        final String label
    ) throws IOException, InterruptedException {
        final HttpRequest request = HttpRequest.newBuilder(
            URI.create(nodeUrl + endpoint))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(payload))
            .build();
        final HttpResponse<String> response = HTTP_CLIENT.send(
            request, BodyHandlers.ofString());
        if (2 != response.statusCode() / 100)
            throw new IOException("HTTP " + response.statusCode());
        final String result = JSON_MAPPER.readTree(response.body())
            .get("message").asText();
        if ("SUCCESS".equals(result))
            System.out.println(label);
        return result;
    }

    public static void main(final String[] args) {
        try {
            new ListenMultisigTransactionFlow().run();
        } catch (final Exception ex) {
            System.out.println(null == ex.getMessage()
                ? ex.toString()
                : ex.getMessage());
        }
    }

    private void run() throws Exception {
        System.out.printf("Using node %s%n", nodeUrl);

        // Set up the multisig and cosignatory accounts
        final String multisigPublicKeyString = System.getenv()
            .getOrDefault(
            "MULTISIG_PUBLIC_KEY",
            "D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5"
                + "ACF2");
        final CryptoTypes.PublicKey multisigPublicKey =
            new CryptoTypes.PublicKey(multisigPublicKeyString);
        final String multisigAddress = facade.network.publicKeyToAddress(
            multisigPublicKey).toString();
        System.out.printf("Multisig address: %s%n", multisigAddress);
        final KeyPair cosignatory0KeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(System.getenv().getOrDefault(
                "COSIGNATORY0_PRIVATE_KEY", "0".repeat(63) + "2")));
        System.out.printf("Cosignatory 0 public key: %s%n",
            cosignatory0KeyPair.getPublicKey());
        final KeyPair cosignatory1KeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(System.getenv().getOrDefault(
                "COSIGNATORY1_PRIVATE_KEY", "0".repeat(63) + "3")));
        System.out.printf("Cosignatory 1 public key: %s%n",
            cosignatory1KeyPair.getPublicKey());

        // [Cosignatory 0] Build and sign the multisig transaction
        final Transaction transferTransaction =
            facade.createTransactionFromTypedDescriptor(
                new TransferTransactionV2Descriptor(
                    new Address(multisigAddress),
                    new Amount(1_000_000), null, List.of()),
                multisigPublicKey, 0, 2 * 60 * 60);
        transferTransaction.setFee(new Amount(
            FeeCalculator.calculateTransactionFee(transferTransaction)));
        final Transaction transaction =
            facade.createTransactionFromTypedDescriptor(
                new MultisigTransactionV1Descriptor(
                    NemTransactionFactory.toNonVerifiableTransaction(
                        transferTransaction), List.of()),
                cosignatory0KeyPair.getPublicKey(), 0, 2 * 60 * 60);
        transaction.setFee(new Amount(
            FeeCalculator.calculateTransactionFee(transaction)));
        final String payload = NemTransactionFactory.attachSignature(
            transaction,
            facade.signTransaction(cosignatory0KeyPair, transaction));
        final String transactionHash = facade.hashTransaction(transaction)
            .toString().toUpperCase();
        final String shortHash = transactionHash.substring(0, 16);
        System.out.printf(
            "[Cosignatory 0] Built multisig transaction %s...%n",
            shortHash);

        // [Cosignatory 1] Connect to the WebSocket
        final Session session = ContainerProvider.getWebSocketContainer()
            .connectToServer(this,
                URI.create(sockJsUrl(wsUrl + "/w/messages")));
        final RemoteEndpoint.Basic remote = session.getBasicRemote();
        rawFrames.take(); // consume the SockJS open frame
        sendFrame(remote, "CONNECT\naccept-version:1.1,1.0\n"
            + "heart-beat:0,0\nhost:" + nodeUrl + "\n\n\0");
        System.out.printf("[Cosignatory 1] Connected to %s%n", wsUrl);

        // [Cosignatory 1] Subscribe to the multisig account
        // channels
        final String accountChannel = "/account/" + multisigAddress;
        final Map<String, String> channels = new HashMap<>();
        channels.put(accountChannel, "id-0");
        channels.put("/unconfirmed/" + multisigAddress, "id-1");
        channels.put("/transactions/" + multisigAddress, "id-2");
        for (final Map.Entry<String, String> channel
            : channels.entrySet()) {
            subscribe(remote, channel.getKey(), channel.getValue());
            System.out.printf("[Cosignatory 1] Subscribed to %s channel%n",
                channel.getKey());
        }

        // [Cosignatory 1] Register the multisig account
        send(remote, "/w/api/account/get", JSON_MAPPER.createObjectNode()
            .put("account", multisigAddress).toString());
        while (true) {
            final StompMessage message = nextMessage();
            if (accountChannel.equals(message.destination())) {
                final String balance = JSON_MAPPER.readTree(message.body())
                    .get("account").get("balance").asText();
                System.out.printf("Account update: balance=%s%n", balance);
                break;
            }
        }
        System.out.println(
            "[Cosignatory 1] Multisig account registered");

        // [Cosignatory 0] Announce the multisig transaction
        final String result = announceTransaction(payload,
            "/transaction/announce",
            "[Cosignatory 0] Announcing multisig transaction "
                + shortHash + "...");
        if ("SUCCESS".equals(result)) {
            // The transaction is now waiting for the second signature

            String innerTransactionHash = null;
            boolean cosigned = false;
            boolean confirmed = false;
            while (true) {
                final StompMessage message = nextMessage();
                final JsonNode body = JSON_MAPPER.readTree(message.body());
                if (accountChannel.equals(message.destination())) {
                    System.out.printf("Account update: balance=%s%n",
                        body.get("account").get("balance").asText());
                    if (confirmed)
                        break;
                } else if (message.destination()
                    .contains("/unconfirmed/") && !cosigned) {
                    // Select the pending multisig transaction
                    final JsonNode otherTransaction = body
                        .get("transaction").get("otherTrans");
                    if (null == otherTransaction
                        || !multisigPublicKeyString.equalsIgnoreCase(
                            otherTransaction.get("signer").asText()))
                        continue;
                    innerTransactionHash = body.get("meta")
                        .get("innerHash").get("data").asText();
                    System.out.printf("unconfirmed: innerHash=%s...%n",
                        innerTransactionHash.substring(0, 16));

                    // Cosign the pending transaction
                    final Transaction cosignature =
                        facade.createTransactionFromTypedDescriptor(
                            new CosignatureV1Descriptor(
                                new CryptoTypes.Hash256(
                                    innerTransactionHash),
                                new Address(multisigAddress)),
                            cosignatory1KeyPair.getPublicKey(),
                            0, 2 * 60 * 60);
                    cosignature.setFee(new Amount(
                        FeeCalculator.calculateTransactionFee(
                            cosignature)));
                    final String cosignaturePayload =
                        NemTransactionFactory.attachSignature(
                            cosignature, facade.signTransaction(
                                cosignatory1KeyPair, cosignature));
                    final String cosignatureResult = announceTransaction(
                        cosignaturePayload, "/transaction/announce",
                        "[Cosignatory 1] Announced cosignature");
                    if (!"SUCCESS".equals(cosignatureResult)) {
                        System.out.printf("Cosignature rejected: %s%n",
                            cosignatureResult);
                        break;
                    }
                    cosigned = true;
                } else if (message.destination()
                    .contains("/transactions/")) {
                    // [Cosignatory 1] Wait for confirmation
                    final String messageHash = body.get("meta")
                        .get("innerHash").get("data").asText();
                    System.out.printf("confirmed: innerHash=%s...%n",
                        messageHash.substring(0, 16));
                    if (messageHash.equals(innerTransactionHash)
                        && !confirmed) {
                        System.out.println(
                            "Multisig transaction confirmed");
                        confirmed = true;
                    }
                }
            }
        } else {
            System.out.printf("Transaction rejected: %s%n", result);
        }
        // [Cosignatory 1] Unsubscribe before closing
        for (final String id : channels.values())
            sendFrame(remote, "UNSUBSCRIBE\nid:" + id + "\n\n\0");
        System.out.println("Unsubscribed from all channels");
        sendFrame(remote, "DISCONNECT\n\n\0");
        session.close();
    }

    @OnMessage
    public void onMessage(final String frame) {
        rawFrames.add(frame);
    }

    private StompMessage nextMessage() throws Exception {
        while (true) {
            final String raw = rawFrames.take();
            if (!raw.startsWith("a"))
                continue;
            for (final JsonNode item
                : JSON_MAPPER.readTree(raw.substring(1))) {
                final String frame = item.asText();
                if (!frame.startsWith("MESSAGE\n"))
                    continue;
                final int separator = frame.indexOf("\n\n");
                final String headers = frame.substring(0, separator);
                String destination = "";
                for (final String line : headers.split("\n"))
                    if (line.startsWith("destination:"))
                        destination = line.substring(12);
                final String body = frame.substring(separator + 2)
                    .replace("\0", "");
                return new StompMessage(destination, body);
            }
        }
    }

    private static String sockJsUrl(final String endpoint) {
        final int server = ThreadLocalRandom.current().nextInt(100, 1000);
        return endpoint.replaceFirst("http", "ws") + "/" + server
            + "/" + UUID.randomUUID().toString().replace("-", "")
            + "/websocket";
    }

    private static void subscribe(
        final RemoteEndpoint.Basic remote,
        final String destination,
        final String id
    ) throws IOException {
        sendFrame(remote, "SUBSCRIBE\nid:" + id + "\ndestination:"
            + destination + "\nack:auto\n\n\0");
    }

    private static void send(
        final RemoteEndpoint.Basic remote,
        final String destination,
        final String body
    ) throws IOException {
        sendFrame(remote, "SEND\ndestination:" + destination
            + "\ncontent-type:application/json\ncontent-length:"
            + body.length() + "\n\n" + body + "\0");
    }

    private static void sendFrame(
        final RemoteEndpoint.Basic remote,
        final String frame
    ) throws IOException {
        final ArrayNode payload = JSON_MAPPER.createArrayNode();
        payload.add(frame);
        remote.sendText(payload.toString());
    }

    private record StompMessage(String destination, String body) {}
}

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. Any cosignatory of the multisig account can take either role.

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}`);
        // Set up the multisig and cosignatory accounts
        final String multisigPublicKeyString = System.getenv()
            .getOrDefault(
            "MULTISIG_PUBLIC_KEY",
            "D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5"
                + "ACF2");
        final CryptoTypes.PublicKey multisigPublicKey =
            new CryptoTypes.PublicKey(multisigPublicKeyString);
        final String multisigAddress = facade.network.publicKeyToAddress(
            multisigPublicKey).toString();
        System.out.printf("Multisig address: %s%n", multisigAddress);
        final KeyPair cosignatory0KeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(System.getenv().getOrDefault(
                "COSIGNATORY0_PRIVATE_KEY", "0".repeat(63) + "2")));
        System.out.printf("Cosignatory 0 public key: %s%n",
            cosignatory0KeyPair.getPublicKey());
        final KeyPair cosignatory1KeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(System.getenv().getOrDefault(
                "COSIGNATORY1_PRIVATE_KEY", "0".repeat(63) + "3")));
        System.out.printf("Cosignatory 1 public key: %s%n",
            cosignatory1KeyPair.getPublicKey());

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
    transfer_transaction = facade.create_transaction_from_descriptor({
        'type': 'transfer_transaction_v2',
        'recipient_address': multisig_address,
        'amount': 1_000_000  # 1 XEM
    }, multisig_public_key, 0, 2 * 60 * 60)
    transfer_transaction.fee = Amount(
        calculate_transaction_fee(transfer_transaction))

    transaction = facade.create_transaction_from_descriptor({
        'type': 'multisig_transaction_v1',
        'inner_transaction':
            facade.transaction_factory.to_non_verifiable_transaction(
                transfer_transaction)
    }, cosignatory0_key_pair.public_key, 0, 2 * 60 * 60)
    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 transferTransaction = facade.createTransactionFromTypedDescriptor(
        new descriptors.TransferTransactionV2Descriptor(
            new Address(multisigAddress),
            new models.Amount(1_000_000n)), // 1 XEM
        multisigPublicKey,
        0n,
        2 * 60 * 60);
    transferTransaction.fee = new models.Amount(
        calculateTransactionFee(transferTransaction));

    const transaction = facade.createTransactionFromTypedDescriptor(
        new descriptors.MultisigTransactionV1Descriptor(
            facade.transactionFactory.static.toNonVerifiableTransaction(
                transferTransaction)),
        cosignatory0KeyPair.publicKey,
        0n,
        2 * 60 * 60);
    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] Build and sign the multisig transaction
        final Transaction transferTransaction =
            facade.createTransactionFromTypedDescriptor(
                new TransferTransactionV2Descriptor(
                    new Address(multisigAddress),
                    new Amount(1_000_000), null, List.of()),
                multisigPublicKey, 0, 2 * 60 * 60);
        transferTransaction.setFee(new Amount(
            FeeCalculator.calculateTransactionFee(transferTransaction)));
        final Transaction transaction =
            facade.createTransactionFromTypedDescriptor(
                new MultisigTransactionV1Descriptor(
                    NemTransactionFactory.toNonVerifiableTransaction(
                        transferTransaction), List.of()),
                cosignatory0KeyPair.getPublicKey(), 0, 2 * 60 * 60);
        transaction.setFee(new Amount(
            FeeCalculator.calculateTransactionFee(transaction)));
        final String payload = NemTransactionFactory.attachSignature(
            transaction,
            facade.signTransaction(cosignatory0KeyPair, transaction));
        final String transactionHash = facade.hashTransaction(transaction)
            .toString().toUpperCase();
        final String shortHash = transactionHash.substring(0, 16);
        System.out.printf(
            "[Cosignatory 0] Built multisig transaction %s...%n",
            shortHash);

Cosignatory 0 builds an inner transfer of 1 XEM from the multisig account to itself, wraps it in a MultisigTransactionV1, and signs it. The facade derives each transaction's timestamp and deadline from the two-hour deadline duration. 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] Connect to the WebSocket
        final Session session = ContainerProvider.getWebSocketContainer()
            .connectToServer(this,
                URI.create(sockJsUrl(wsUrl + "/w/messages")));
        final RemoteEndpoint.Basic remote = session.getBasicRemote();
        rawFrames.take(); // consume the SockJS open frame
        sendFrame(remote, "CONNECT\naccept-version:1.1,1.0\n"
            + "heart-beat:0,0\nhost:" + nodeUrl + "\n\n\0");
        System.out.printf("[Cosignatory 1] Connected to %s%n", wsUrl);

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
        channels = {
            f'/account/{multisig_address}': '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 subscriptions = [
        {
            channel: `/account/${multisigAddress}`,
            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] Subscribe to the multisig account
        // channels
        final String accountChannel = "/account/" + multisigAddress;
        final Map<String, String> channels = new HashMap<>();
        channels.put(accountChannel, "id-0");
        channels.put("/unconfirmed/" + multisigAddress, "id-1");
        channels.put("/transactions/" + multisigAddress, "id-2");
        for (final Map.Entry<String, String> channel
            : channels.entrySet()) {
            subscribe(remote, channel.getKey(), channel.getValue());
            System.out.printf("[Cosignatory 1] Subscribed to %s channel%n",
                channel.getKey());
        }

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.

Unlike the non-multisig case, the channels listen to the activity of the multisig account, not the cosignatory account.

The reason is that the node only notifies the initiating cosignatory and the accounts involved in the inner transaction, Cosignatory 0 and the multisig account in this example. Cosignatories waiting to approve the transaction, such as Cosignatory 1, receive no notifications on their own addresses, so they must subscribe to the multisig account's address instead.

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/' in 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');
        // [Cosignatory 1] Register the multisig account
        send(remote, "/w/api/account/get", JSON_MAPPER.createObjectNode()
            .put("account", multisigAddress).toString());
        while (true) {
            final StompMessage message = nextMessage();
            if (accountChannel.equals(message.destination())) {
                final String balance = JSON_MAPPER.readTree(message.body())
                    .get("account").get("balance").asText();
                System.out.printf("Account update: balance=%s%n", balance);
                break;
            }
        }
        System.out.println(
            "[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
        announce_result = announce_transaction(
            json_payload, '/transaction/announce',
            '[Cosignatory 0] Announcing multisig transaction '
            f'{transaction_hash[:16]}...')
        if 'SUCCESS' != announce_result:
            print(f'Transaction rejected: {announce_result}')
            return
        # The transaction is now waiting for the second signature
    // [Cosignatory 0] Announce the multisig transaction
    const announceResult = await announceTransaction(
        jsonPayload, '/transaction/announce',
        '[Cosignatory 0] Announcing multisig transaction ' +
        `${shortHash}...`);
    if ('SUCCESS' === announceResult) {
        // The transaction is now waiting for the second signature
        // [Cosignatory 0] Announce the multisig transaction
        final String result = announceTransaction(payload,
            "/transaction/announce",
            "[Cosignatory 0] Announcing multisig transaction "
                + shortHash + "...");
        if ("SUCCESS".equals(result)) {
            // 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)}...`);
                    // Select the pending multisig transaction
                    final JsonNode otherTransaction = body
                        .get("transaction").get("otherTrans");
                    if (null == otherTransaction
                        || !multisigPublicKeyString.equalsIgnoreCase(
                            otherTransaction.get("signer").asText()))
                        continue;
                    innerTransactionHash = body.get("meta")
                        .get("innerHash").get("data").asText();
                    System.out.printf("unconfirmed: innerHash=%s...%n",
                        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. Nothing guarantees that a pending transaction is the expected one, so 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.create_transaction_from_descriptor({
                'type': 'cosignature_v1',
                # Hash of the inner transfer transaction
                'other_transaction_hash': inner_transaction_hash,
                # Address of the multisig account
                'multisig_account_address': multisig_address
            }, cosignatory1_key_pair.public_key, 0, 2 * 60 * 60)
            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_result = announce_transaction(
                cosignature_payload, '/transaction/announce',
                '[Cosignatory 1] Announced cosignature')
            if 'SUCCESS' != cosignature_result:
                print(f'Cosignature rejected: {cosignature_result}')
                return
            break
        // [Cosignatory 1] Cosign the pending transaction
        const cosignature = facade.createTransactionFromTypedDescriptor(
            new descriptors.CosignatureV1Descriptor(
                // Hash of the inner transfer transaction
                new Hash256(innerTransactionHash),
                // Address of the multisig account
                new Address(multisigAddress)),
            // This is the cosignatory providing the second signature
            cosignatory1KeyPair.publicKey,
            0n,
            2 * 60 * 60);
        cosignature.fee = new models.Amount(
            calculateTransactionFee(cosignature));
        const cosignatureSignature = facade.signTransaction(
            cosignatory1KeyPair, cosignature);
        const cosignaturePayload = facade.transactionFactory.static
            .attachSignature(cosignature, cosignatureSignature);
        const cosignatureResult = await announceTransaction(
            cosignaturePayload, '/transaction/announce',
            '[Cosignatory 1] Announced cosignature');
        if ('SUCCESS' !== cosignatureResult) {
            console.log(
                `Cosignature rejected: ${cosignatureResult}`);
            resolveCosigned(false);
            return;
        }
        resolveCosigned(true);
                    // Cosign the pending transaction
                    final Transaction cosignature =
                        facade.createTransactionFromTypedDescriptor(
                            new CosignatureV1Descriptor(
                                new CryptoTypes.Hash256(
                                    innerTransactionHash),
                                new Address(multisigAddress)),
                            cosignatory1KeyPair.getPublicKey(),
                            0, 2 * 60 * 60);
                    cosignature.setFee(new Amount(
                        FeeCalculator.calculateTransactionFee(
                            cosignature)));
                    final String cosignaturePayload =
                        NemTransactionFactory.attachSignature(
                            cosignature, facade.signTransaction(
                                cosignatory1KeyPair, cosignature));
                    final String cosignatureResult = announceTransaction(
                        cosignaturePayload, "/transaction/announce",
                        "[Cosignatory 1] Announced cosignature");
                    if (!"SUCCESS".equals(cosignatureResult)) {
                        System.out.printf("Cosignature rejected: %s%n",
                            cosignatureResult);
                        break;
                    }
                    cosigned = 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/' in 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();
    };
                    // [Cosignatory 1] Wait for confirmation
                    final String messageHash = body.get("meta")
                        .get("innerHash").get("data").asText();
                    System.out.printf("confirmed: innerHash=%s...%n",
                        messageHash.substring(0, 16));
                    if (messageHash.equals(innerTransactionHash)
                        && !confirmed) {
                        System.out.println(
                            "Multisig transaction confirmed");
                        confirmed = true;
                    }

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();
        // [Cosignatory 1] Unsubscribe before closing
        for (final String id : channels.values())
            sendFrame(remote, "UNSUBSCRIBE\nid:" + id + "\n\n\0");
        System.out.println("Unsubscribed from all channels");
        sendFrame(remote, "DISCONNECT\n\n\0");
        session.close();

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 account/{address} WS
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