Skip to content

Signing a Transaction from a Multisignature Account⚓︎

This tutorial transfers 1 XYM from an account to itself, mirroring the Creating a Transfer Transaction tutorial.

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

This tutorial uses the multisig configuration created in the Configuring a Multisignature Account tutorial, with Cosignatory 0 initiating and signing the transaction:

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

Prerequisites⚓︎

Before you start, make sure to:

Additionally, review the Transfer transaction tutorial to understand how transactions are announced and confirmed, and the Complete Aggregate transaction tutorial to understand how aggregate transactions work.

Full Code⚓︎

import json
import os
import time
import urllib.request

from symbolchain.CryptoTypes import PrivateKey
from symbolchain.facade.SymbolFacade import SymbolFacade
from symbolchain.sc import Amount
from symbolchain.symbol.Network import NetworkTimestamp
from symbolchain.symbol.IdGenerator import generate_mosaic_alias_id

NODE_URL = 'https://reference.symboltest.net:3001'
print(f'Using node {NODE_URL}')


MULTISIG_PRIVATE_KEY = os.getenv(
    'MULTISIG_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000001')
multisig_key_pair = SymbolFacade.KeyPair(
    PrivateKey(MULTISIG_PRIVATE_KEY))
print(f'Multisig public key: {multisig_key_pair.public_key}')
COSIGNATORY0_PRIVATE_KEY = os.getenv(
    'COSIGNATORY0_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000002')
cosignatory_key_pair = SymbolFacade.KeyPair(
    PrivateKey(COSIGNATORY0_PRIVATE_KEY))
print(f'Cosignatory public key: {cosignatory_key_pair.public_key}')

facade = SymbolFacade('testnet')

try:
    # Fetch current network time
    time_path = '/node/time'
    print(f'Fetching current network time from {time_path}')
    with urllib.request.urlopen(f'{NODE_URL}{time_path}') as response:
        response_json = json.loads(response.read().decode())
        timestamp = NetworkTimestamp(int(
            response_json['communicationTimestamps']['receiveTimestamp']))
        print(f'  Network time: {timestamp.timestamp} ms since nemesis')

    # Fetch recommended fees
    fee_path = '/network/fees/transaction'
    print(f'Fetching recommended fees from {fee_path}')
    with urllib.request.urlopen(f'{NODE_URL}{fee_path}') as response:
        response_json = json.loads(response.read().decode())
        median_mult = response_json['medianFeeMultiplier']
        minimum_mult = response_json['minFeeMultiplier']
        fee_mult = max(median_mult, minimum_mult)
        print(f'  Fee multiplier: {fee_mult}')

    # Build the embedded transfer transaction
    transfer_transaction = facade.transaction_factory.create_embedded({
        'type': 'transfer_transaction_v1',
        'signer_public_key': multisig_key_pair.public_key,
        'recipient_address':
            facade.network.public_key_to_address(
                multisig_key_pair.public_key),
        'mosaics': [{
            'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
            'amount': 1_000_000 # 1 XYM
        }]
    })

    # Build the wrapper aggregate transaction
    transaction = facade.transaction_factory.create({
        'type': 'aggregate_complete_transaction_v3',
        # This is the account that will pay for the transaction
        'signer_public_key': cosignatory_key_pair.public_key,
        'deadline': timestamp.add_hours(2).timestamp,
        'transactions_hash': facade.hash_embedded_transactions(
            [transfer_transaction]),
        'transactions': [transfer_transaction]
    })
    transaction.fee = Amount(fee_mult * transaction.size)

    # Sign the aggregate transaction using the cosignatory's signature
    json_payload = facade.transaction_factory.attach_signature(
        transaction,
        facade.sign_transaction(cosignatory_key_pair, transaction))
    print('Built transaction:')
    print(json.dumps(transaction.to_json(), indent=2))

    # Announce the transaction
    announce_path = '/transactions'
    print(f'Announcing transaction to {announce_path}')
    announce_request = urllib.request.Request(
        f'{NODE_URL}{announce_path}',
        data=json_payload.encode(),
        headers={ 'Content-Type': 'application/json' },
        method='PUT'
    )
    with urllib.request.urlopen(announce_request) as response:
        print(f'  Response: {response.read().decode()}')

    # Wait for confirmation
    status_path = (
        f'/transactionStatus/{facade.hash_transaction(transaction)}')
    print(f'Waiting for confirmation from {status_path}')
    for attempt in range(60):
        time.sleep(1)
        try:
            with urllib.request.urlopen(
                f'{NODE_URL}{status_path}'
            ) as response:
                status = json.loads(response.read().decode())
                print(f'  Transaction status: {status['group']}')
                if status['group'] == 'confirmed':
                    print(f'Transaction confirmed in {attempt} seconds')
                    break
                if status['group'] == 'failed':
                    print(f'Transaction failed: {status['code']}')
                    break
        except urllib.error.HTTPError as e:
            print(f'  Transaction status: unknown | Cause: ({e.msg})')
    else:
        print('Confirmation took too long.')

except urllib.error.URLError as e:
    print(e.reason)

Download source

import { PrivateKey } from 'symbol-sdk';
import {
    SymbolFacade,
    NetworkTimestamp,
    models,
    generateMosaicAliasId
} from 'symbol-sdk/symbol';

const NODE_URL = 'https://reference.symboltest.net:3001';
console.log('Using node', NODE_URL);

const MULTISIG_PRIVATE_KEY = process.env.MULTISIG_PRIVATE_KEY || (
    '0000000000000000000000000000000000000000000000000000000000000001');
const multisigKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(MULTISIG_PRIVATE_KEY));
console.log(`Multisig public key: ${multisigKeyPair.publicKey}`);
const COSIGNATORY0_PRIVATE_KEY = process.env.COSIGNATORY0_PRIVATE_KEY || (
    '0000000000000000000000000000000000000000000000000000000000000002');
const cosignatoryKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(COSIGNATORY0_PRIVATE_KEY));
console.log(`Cosignatory public key: ${cosignatoryKeyPair.publicKey}`);

const facade = new SymbolFacade('testnet');

try {
    // Fetch current network time
    const timePath = '/node/time';
    console.log('Fetching current network time from', timePath);
    const timeResponse = await fetch(`${NODE_URL}${timePath}`);
    const timeJSON = await timeResponse.json();
    const timestamp = new NetworkTimestamp(
        timeJSON.communicationTimestamps.receiveTimestamp);
    console.log('  Network time:', timestamp.timestamp,
        'ms since nemesis');

    // Fetch recommended fees
    const feePath = '/network/fees/transaction';
    console.log('Fetching recommended fees from', feePath);
    const feeResponse = await fetch(`${NODE_URL}${feePath}`);
    const feeJSON = await feeResponse.json();
    const medianMult = feeJSON.medianFeeMultiplier;
    const minimumMult = feeJSON.minFeeMultiplier;
    const feeMult = Math.max(medianMult, minimumMult);
    console.log('  Fee multiplier:', feeMult);

    // Build the embedded transfer transaction
    const transferTransaction = facade.transactionFactory.createEmbedded({
        type: 'transfer_transaction_v1',
        signerPublicKey: multisigKeyPair.publicKey.toString(),
        recipientAddress: facade.network.publicKeyToAddress(
            multisigKeyPair.publicKey).toString(),
        mosaics: [{
            mosaicId: generateMosaicAliasId('symbol.xym'),
            amount: 1_000_000n // 1 XYM
        }]
    });

    // Build the wrapper aggregate transaction
    const transaction = facade.transactionFactory.create({
        type: 'aggregate_complete_transaction_v3',
        // This is the account that will pay for the transaction
        signerPublicKey: cosignatoryKeyPair.publicKey.toString(),
        deadline: timestamp.addHours(2).timestamp,
        transactionsHash: facade.static.hashEmbeddedTransactions(
            [transferTransaction]),
        transactions: [transferTransaction]
    });
    transaction.fee = new models.Amount(feeMult * transaction.size);

    // Sign the aggregate transaction using the cosignatory's signature
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction,
        facade.signTransaction(cosignatoryKeyPair, transaction));
    console.log('Built transaction:');
    console.dir(transaction.toJson(), { colors: true });

    // Announce the transaction
    const announcePath = '/transactions';
    console.log('Announcing transaction to', announcePath);
    const announceResponse = await fetch(`${NODE_URL}${announcePath}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: jsonPayload
    });
    console.log('  Response:', await announceResponse.text());

    // Wait for confirmation
    const transactionHash =
        facade.hashTransaction(transaction).toString();
    const statusPath = `/transactionStatus/${transactionHash}`;
    console.log('Waiting for confirmation from', statusPath);

    let attempt = 0;

    function pollStatus() {
        attempt++;

        if (attempt > 60) {
            console.warn('Confirmation took too long.');
            return;
        }

        return fetch(`${NODE_URL}${statusPath}`)
            .then(response => {
                if (!response.ok) {
                    console.log('  Transaction status: unknown | Cause:',
                        response.statusText);
                    // HTTP error: schedule a retry
                    return new Promise(resolve =>
                        setTimeout(resolve, 1000)).then(pollStatus);
                }
                return response.json();
            })
            .then(status => {
                // Skip if previous step scheduled a retry
                if (!status) return;

                console.log('  Transaction status:', status.group);

                if (status.group === 'confirmed') {
                    console.log('Transaction confirmed in', attempt,
                        'seconds');
                } else if (status.group === 'failed') {
                    console.log('Transaction failed:', status.code);
                } else {
                    // Transaction unconfirmed: schedule a retry
                    return new Promise(resolve =>
                        setTimeout(resolve, 1000)).then(pollStatus);
                }
            });
    }
    pollStatus();
} catch (e) {
    console.error(e.message, '| Cause:', e.cause?.code ?? 'unknown');
}

Download source

Code Explanation⚓︎

In general, signing a transaction on behalf of a multisig account only requires wrapping it in an aggregate transaction that provides the required cosignatures.

This tutorial builds an embedded transaction containing the transfer, using the multisig account as the signer, since this is the origin of the transfer. A complete aggregate transaction then wraps the transfer transaction, signed by the cosignatory, since this is the account that can authorize the transaction.

Setting Up the Accounts⚓︎

MULTISIG_PRIVATE_KEY = os.getenv(
    'MULTISIG_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000001')
multisig_key_pair = SymbolFacade.KeyPair(
    PrivateKey(MULTISIG_PRIVATE_KEY))
print(f'Multisig public key: {multisig_key_pair.public_key}')
COSIGNATORY0_PRIVATE_KEY = os.getenv(
    'COSIGNATORY0_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000002')
cosignatory_key_pair = SymbolFacade.KeyPair(
    PrivateKey(COSIGNATORY0_PRIVATE_KEY))
print(f'Cosignatory public key: {cosignatory_key_pair.public_key}')
const MULTISIG_PRIVATE_KEY = process.env.MULTISIG_PRIVATE_KEY || (
    '0000000000000000000000000000000000000000000000000000000000000001');
const multisigKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(MULTISIG_PRIVATE_KEY));
console.log(`Multisig public key: ${multisigKeyPair.publicKey}`);
const COSIGNATORY0_PRIVATE_KEY = process.env.COSIGNATORY0_PRIVATE_KEY || (
    '0000000000000000000000000000000000000000000000000000000000000002');
const cosignatoryKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(COSIGNATORY0_PRIVATE_KEY));
console.log(`Cosignatory public key: ${cosignatoryKeyPair.publicKey}`);

The tutorial requires two separate accounts. Their private keys can be provided through environment variables. If not set, default values are used:

Environment Variable Default value Purpose
MULTISIG_PRIVATE_KEY 0000..0001 Multisig account
COSIGNATORY0_PRIVATE_KEY 0000..0002 Cosignatory account

Each private key is a 64-character hexadecimal string.

The cosignatory account must hold enough funds to pay the transaction fee. If the default values are used, these accounts may already be funded.

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

Fetching Network Time and Fees⚓︎

    # Fetch current network time
    time_path = '/node/time'
    print(f'Fetching current network time from {time_path}')
    with urllib.request.urlopen(f'{NODE_URL}{time_path}') as response:
        response_json = json.loads(response.read().decode())
        timestamp = NetworkTimestamp(int(
            response_json['communicationTimestamps']['receiveTimestamp']))
        print(f'  Network time: {timestamp.timestamp} ms since nemesis')

    # Fetch recommended fees
    fee_path = '/network/fees/transaction'
    print(f'Fetching recommended fees from {fee_path}')
    with urllib.request.urlopen(f'{NODE_URL}{fee_path}') as response:
        response_json = json.loads(response.read().decode())
        median_mult = response_json['medianFeeMultiplier']
        minimum_mult = response_json['minFeeMultiplier']
        fee_mult = max(median_mult, minimum_mult)
        print(f'  Fee multiplier: {fee_mult}')
    // Fetch current network time
    const timePath = '/node/time';
    console.log('Fetching current network time from', timePath);
    const timeResponse = await fetch(`${NODE_URL}${timePath}`);
    const timeJSON = await timeResponse.json();
    const timestamp = new NetworkTimestamp(
        timeJSON.communicationTimestamps.receiveTimestamp);
    console.log('  Network time:', timestamp.timestamp,
        'ms since nemesis');

    // Fetch recommended fees
    const feePath = '/network/fees/transaction';
    console.log('Fetching recommended fees from', feePath);
    const feeResponse = await fetch(`${NODE_URL}${feePath}`);
    const feeJSON = await feeResponse.json();
    const medianMult = feeJSON.medianFeeMultiplier;
    const minimumMult = feeJSON.minFeeMultiplier;
    const feeMult = Math.max(medianMult, minimumMult);
    console.log('  Fee multiplier:', feeMult);

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

Building the Transaction⚓︎

    # Build the embedded transfer transaction
    transfer_transaction = facade.transaction_factory.create_embedded({
        'type': 'transfer_transaction_v1',
        'signer_public_key': multisig_key_pair.public_key,
        'recipient_address':
            facade.network.public_key_to_address(
                multisig_key_pair.public_key),
        'mosaics': [{
            'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
            'amount': 1_000_000 # 1 XYM
        }]
    })
    // Build the embedded transfer transaction
    const transferTransaction = facade.transactionFactory.createEmbedded({
        type: 'transfer_transaction_v1',
        signerPublicKey: multisigKeyPair.publicKey.toString(),
        recipientAddress: facade.network.publicKeyToAddress(
            multisigKeyPair.publicKey).toString(),
        mosaics: [{
            mosaicId: generateMosaicAliasId('symbol.xym'),
            amount: 1_000_000n // 1 XYM
        }]
    });

The embedded transfer transaction includes the following fields:

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

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

  • mosaics: 1'000'000 atomic units of the symbol.xym mosaic, corresponding to 1 XYM, as explained in the Transfer Transaction tutorial.

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

    # Build the wrapper aggregate transaction
    transaction = facade.transaction_factory.create({
        'type': 'aggregate_complete_transaction_v3',
        # This is the account that will pay for the transaction
        'signer_public_key': cosignatory_key_pair.public_key,
        'deadline': timestamp.add_hours(2).timestamp,
        'transactions_hash': facade.hash_embedded_transactions(
            [transfer_transaction]),
        'transactions': [transfer_transaction]
    })
    transaction.fee = Amount(fee_mult * transaction.size)
    // Build the wrapper aggregate transaction
    const transaction = facade.transactionFactory.create({
        type: 'aggregate_complete_transaction_v3',
        // This is the account that will pay for the transaction
        signerPublicKey: cosignatoryKeyPair.publicKey.toString(),
        deadline: timestamp.addHours(2).timestamp,
        transactionsHash: facade.static.hashEmbeddedTransactions(
            [transferTransaction]),
        transactions: [transferTransaction]
    });
    transaction.fee = new models.Amount(feeMult * transaction.size);

Its most relevant fields are:

  • signer_public_key: this time this is the public key of the cosignatory that will be authorizing the transaction and paying its fees.

  • transactions: the list of embedded transactions. This example has only one, but there could be any number of them.

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

Finally, the aggregate transaction is signed by the cosignatory:

    # Sign the aggregate transaction using the cosignatory's signature
    json_payload = facade.transaction_factory.attach_signature(
        transaction,
        facade.sign_transaction(cosignatory_key_pair, transaction))
    print('Built transaction:')
    print(json.dumps(transaction.to_json(), indent=2))
    // Sign the aggregate transaction using the cosignatory's signature
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction,
        facade.signTransaction(cosignatoryKeyPair, transaction));
    console.log('Built transaction:');
    console.dir(transaction.toJson(), { colors: true });

Multiple cosignatories

In other multisig configurations, more signatures might be required. In that case, they are attached using instead of .

See the Configuring a Multisignature Account tutorial for an example.

Submitting the Aggregate Transaction⚓︎

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

    # Announce the transaction
    announce_path = '/transactions'
    print(f'Announcing transaction to {announce_path}')
    announce_request = urllib.request.Request(
        f'{NODE_URL}{announce_path}',
        data=json_payload.encode(),
        headers={ 'Content-Type': 'application/json' },
        method='PUT'
    )
    with urllib.request.urlopen(announce_request) as response:
        print(f'  Response: {response.read().decode()}')

    # Wait for confirmation
    status_path = (
        f'/transactionStatus/{facade.hash_transaction(transaction)}')
    print(f'Waiting for confirmation from {status_path}')
    for attempt in range(60):
        time.sleep(1)
        try:
            with urllib.request.urlopen(
                f'{NODE_URL}{status_path}'
            ) as response:
                status = json.loads(response.read().decode())
                print(f'  Transaction status: {status['group']}')
                if status['group'] == 'confirmed':
                    print(f'Transaction confirmed in {attempt} seconds')
                    break
                if status['group'] == 'failed':
                    print(f'Transaction failed: {status['code']}')
                    break
        except urllib.error.HTTPError as e:
            print(f'  Transaction status: unknown | Cause: ({e.msg})')
    else:
        print('Confirmation took too long.')
    // Announce the transaction
    const announcePath = '/transactions';
    console.log('Announcing transaction to', announcePath);
    const announceResponse = await fetch(`${NODE_URL}${announcePath}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: jsonPayload
    });
    console.log('  Response:', await announceResponse.text());

    // Wait for confirmation
    const transactionHash =
        facade.hashTransaction(transaction).toString();
    const statusPath = `/transactionStatus/${transactionHash}`;
    console.log('Waiting for confirmation from', statusPath);

    let attempt = 0;

    function pollStatus() {
        attempt++;

        if (attempt > 60) {
            console.warn('Confirmation took too long.');
            return;
        }

        return fetch(`${NODE_URL}${statusPath}`)
            .then(response => {
                if (!response.ok) {
                    console.log('  Transaction status: unknown | Cause:',
                        response.statusText);
                    // HTTP error: schedule a retry
                    return new Promise(resolve =>
                        setTimeout(resolve, 1000)).then(pollStatus);
                }
                return response.json();
            })
            .then(status => {
                // Skip if previous step scheduled a retry
                if (!status) return;

                console.log('  Transaction status:', status.group);

                if (status.group === 'confirmed') {
                    console.log('Transaction confirmed in', attempt,
                        'seconds');
                } else if (status.group === 'failed') {
                    console.log('Transaction failed:', status.code);
                } else {
                    // Transaction unconfirmed: schedule a retry
                    return new Promise(resolve =>
                        setTimeout(resolve, 1000)).then(pollStatus);
                }
            });
    }
    pollStatus();

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

Error message Probable cause
Multisig Operation Prohibited By Account The multisig account tried to sign the aggregate transaction itself.
Aggregate Ineligible Cosignatories The signer is not in the cosignatories list.
Consumer Batch Signature Not Verifiable The signature attached to the aggregate transaction does not match its signer_public_key.

Output⚓︎

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

Using node https://reference.symboltest.net:3001
Multisig public key: 4CB5ABF6AD79FBF5ABBCCAFCC269D85CD2651ED4B885B5869F241AEDF0A5BA29
Cosignatory public key: 7422B9887598068E32C4448A949ADB290D0F4E35B9E01B0EE5F1A1E600FE2674
Fetching current network time from /node/time
  Network time: 102872650165 ms since nemesis
Fetching recommended fees from /network/fees/transaction
  Fee multiplier: 100
Built transaction:
{
  "signature": "A533F9B281174AE537944B25752DB75FFC277CBC3958347E741E53CA4A1D02EBA9C4B6F993DF05A250F07C270E2CF6C21DA6344FE31AE701390BA5AD7BC62F0C",
  "signer_public_key": "7422B9887598068E32C4448A949ADB290D0F4E35B9E01B0EE5F1A1E600FE2674",
  "version": 3,
  "network": 152,
  "type": 16705,
  "fee": "26400",
  "deadline": "102879850165",
  "transactions_hash": "8E64E490EB8B7887CAE6BE6846F67ADEEAE0AF3525CF98D84044ADE9F6BA488F",
  "transactions": [
    {
      "signer_public_key": "4CB5ABF6AD79FBF5ABBCCAFCC269D85CD2651ED4B885B5869F241AEDF0A5BA29",
      "version": 1,
      "network": 152,
      "type": 16724,
      "recipient_address": "987D075454716222F609929E883174AD8C996D5828C938BC",
      "mosaics": [
        {
          "mosaic_id": "16666583871264174062",
          "amount": "1000000"
        }
      ],
      "message": ""
    }
  ],
  "cosignatures": []
}
Announcing transaction to /transactions
  Response: {"message":"packet 9 was pushed to the network via /transactions"}
Waiting for confirmation from /transactionStatus/AB9FB75C150AD471AC73A6CF278D82D122E2583CF02B3B9067274608A7D334E4
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  ...
  Transaction status: confirmed
Transaction confirmed in 9 seconds

Key points in the output:

  • Lines 2-3: Public keys of all involved accounts.
  • Line 11 (signer_public_key): Signer of the aggregate transaction. Note that it matches the cosignatory account.
  • Line 20 (signer_public_key): Signer of the embedded transfer transaction. Note that it matches the multisig account.
  • Line 24 (recipient_address): Encoded address of the multisig account.

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

Conclusion⚓︎

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

In particular, the tutorial showed how to:

Step Related documentation
Wrap transfer in an embedded transaction
Attach signatures in the right place