Skip to content

Listening to Transaction Errors⚓︎

The status/{address} WS WebSocket channel sends real-time notifications when a transaction related to a specific account is rejected by the network. Instead of polling the /transactionStatus/{hash} GET endpoint, the status channel pushes error details as soon as the network rejects a transaction.

This tutorial shows how to subscribe to the status channel and handle rejection notifications. To test the listener, the code deliberately sends an invalid transaction that the network will reject.

Prerequisites⚓︎

Before you start, make sure to:

Additionally, install the language-specific WebSocket library:

Install the websockets library:

pip install websockets

This tutorial uses the native WebSocket API available in Node.js 22 or later. No additional packages are required.

Full Code⚓︎

import asyncio
import json
import os
import urllib.request

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

NODE_URL = os.getenv('NODE_URL', 'https://reference.symboltest.net:3001')
WS_URL = NODE_URL.replace('http', 'ws', 1) + '/ws'
print(f'Using node {NODE_URL}')

MONITOR_ADDRESS = os.getenv(
    'MONITOR_ADDRESS','TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I'
)
print(f'Monitoring address: {MONITOR_ADDRESS}')

SIGNER_PRIVATE_KEY = os.getenv(
    'SIGNER_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000000'
)
facade = SymbolFacade('testnet')
signer_key_pair = SymbolFacade.KeyPair(PrivateKey(SIGNER_PRIVATE_KEY))


async def main():
    async with connect(WS_URL) as websocket:
        # Connect to WebSocket
        response = json.loads(await websocket.recv())
        uid = response['uid']
        print(f'Connected to {WS_URL} with uid {uid}')

        # Subscribe to status channel
        channel = f'status/{MONITOR_ADDRESS}'
        await websocket.send(json.dumps(
            {'uid': uid, 'subscribe': channel}
        ))
        print('Subscribed to status channel')

        # Build a transfer transaction with a non-existent mosaic
        with urllib.request.urlopen(f'{NODE_URL}/node/time') as resp:
            time_json = json.loads(resp.read().decode())
            timestamp = NetworkTimestamp(int(
                time_json['communicationTimestamps']['receiveTimestamp']))

        with urllib.request.urlopen(
            f'{NODE_URL}/network/fees/transaction'
        ) as resp:
            fee_json = json.loads(resp.read().decode())
            fee_mult = max(
                fee_json['medianFeeMultiplier'],
                fee_json['minFeeMultiplier'])

        transaction = facade.transaction_factory.create({
            'type': 'transfer_transaction_v1',
            'signer_public_key': signer_key_pair.public_key,
            'deadline': timestamp.add_hours(2).timestamp,
            'recipient_address': MONITOR_ADDRESS,
            'mosaics': [{
                'mosaic_id': generate_mosaic_alias_id('symbol.unknown'),
                'amount': 1
            }],
        })
        transaction.fee = Amount(fee_mult * transaction.size)

        signature = facade.sign_transaction(signer_key_pair, transaction)
        json_payload = facade.transaction_factory.attach_signature(
            transaction, signature)
        transaction_hash = str(facade.hash_transaction(transaction))

        announce_request = urllib.request.Request(
            f'{NODE_URL}/transactions',
            data=json_payload.encode(),
            headers={'Content-Type': 'application/json'},
            method='PUT'
        )
        with urllib.request.urlopen(announce_request) as resp:
            resp.read()
        print(f'Announced transaction {transaction_hash[:16]}...')

        # Wait for error via WebSocket
        async for raw_message in websocket:
            msg = json.loads(raw_message)
            tx_hash = msg['data']['hash']
            code = msg['data']['code']
            print(
                f'Transaction {tx_hash[:16]}... '
                f'rejected with code: {code}'
            )

            if tx_hash == transaction_hash:
                break

        # Unsubscribe before closing
        await websocket.send(json.dumps(
            {'uid': uid, 'unsubscribe': channel}
        ))
        print('Unsubscribed from status channel')


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

Download source

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

const NODE_URL = process.env.NODE_URL
    || 'https://reference.symboltest.net:3001';
const WS_URL = NODE_URL.replace('http', 'ws') + '/ws';
console.log(`Using node ${NODE_URL}`);

const MONITOR_ADDRESS = process.env.MONITOR_ADDRESS
    || 'TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I';
console.log(`Monitoring address: ${MONITOR_ADDRESS}`);

const SIGNER_PRIVATE_KEY = process.env.SIGNER_PRIVATE_KEY
    || '0000000000000000000000000000000000000000000000000000000000000000';
const facade = new SymbolFacade('testnet');
const signerKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(SIGNER_PRIVATE_KEY));

try {
    // Connect to WebSocket
    const websocket = new WebSocket(WS_URL);
    const uid = await new Promise((resolve) => {
        websocket.addEventListener('message', (event) => {
            const message = JSON.parse(event.data);
            resolve(message.uid);
        }, { once: true });
    });
    console.log(`Connected to ${WS_URL} with uid ${uid}`);

    // Subscribe to status channel
    const channel = `status/${MONITOR_ADDRESS}`;
    websocket.send(JSON.stringify({ uid, subscribe: channel }));
    console.log('Subscribed to status channel');

    // Build a transfer transaction with a non-existent mosaic
    const timeResponse = await fetch(`${NODE_URL}/node/time`);
    const timeJSON = await timeResponse.json();
    const timestamp = new NetworkTimestamp(
        timeJSON.communicationTimestamps.receiveTimestamp);

    const feeResponse = await fetch(
        `${NODE_URL}/network/fees/transaction`);
    const feeJSON = await feeResponse.json();
    const feeMult = Math.max(
        feeJSON.medianFeeMultiplier, feeJSON.minFeeMultiplier);

    const transaction = facade.transactionFactory.create({
        type: 'transfer_transaction_v1',
        signerPublicKey: signerKeyPair.publicKey.toString(),
        deadline: timestamp.addHours(2).timestamp,
        recipientAddress: MONITOR_ADDRESS,
        mosaics: [{
            mosaicId: generateMosaicAliasId('symbol.unknown'),
            amount: 1n
        }],
    });
    transaction.fee = new models.Amount(feeMult * transaction.size);

    const signature = facade.signTransaction(signerKeyPair, transaction);
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction, signature);
    const transactionHash = facade.hashTransaction(transaction).toString();

    const rejected = new Promise((resolve) => {
        websocket.addEventListener('message', (event) => {
            const msg = JSON.parse(event.data);
            const txHash = msg.data.hash;
            const code = msg.data.code;
            console.log(
                `Transaction ${txHash.substring(0, 16)}... `
                + `rejected with code: ${code}`);

            if (txHash === transactionHash) {
                resolve();
            }
        });
    });
    await fetch(`${NODE_URL}/transactions`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: jsonPayload
    });
    console.log(
        'Announced transaction '
        + `${transactionHash.substring(0, 16)}...`);

    // Wait for error via WebSocket
    await rejected;

    // Unsubscribe before closing
    websocket.send(JSON.stringify({ uid, unsubscribe: channel }));
    console.log('Unsubscribed from status channel');
    websocket.close();
} catch (error) {
    console.error(error);
}

Download source

The snippet uses the NODE_URL environment variable to set the Symbol API node. If no value is provided, a default one is used. The WebSocket URL is derived from NODE_URL by replacing the HTTP protocol with the WebSocket protocol and appending /ws.

Code Explanation⚓︎

Setting Up the Monitored Address and Signer⚓︎

MONITOR_ADDRESS = os.getenv(
    'MONITOR_ADDRESS','TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I'
)
print(f'Monitoring address: {MONITOR_ADDRESS}')

SIGNER_PRIVATE_KEY = os.getenv(
    'SIGNER_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000000'
)
facade = SymbolFacade('testnet')
signer_key_pair = SymbolFacade.KeyPair(PrivateKey(SIGNER_PRIVATE_KEY))
const MONITOR_ADDRESS = process.env.MONITOR_ADDRESS
    || 'TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I';
console.log(`Monitoring address: ${MONITOR_ADDRESS}`);

const SIGNER_PRIVATE_KEY = process.env.SIGNER_PRIVATE_KEY
    || '0000000000000000000000000000000000000000000000000000000000000000';
const facade = new SymbolFacade('testnet');
const signerKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(SIGNER_PRIVATE_KEY));

The status channel is scoped to a specific address. The MONITOR_ADDRESS environment variable sets the address to watch. The channel notifies whenever the address participates in a rejected transaction, whether as sender, recipient, or any other role derived from the transaction's content (for example, signer of an embedded transaction in an aggregate transaction). To trigger a rejection, this tutorial sends a transfer transaction with a non-existent mosaic, signed with the private key from SIGNER_PRIVATE_KEY.

If any of these environment variables is not provided, the tutorial provides default values that correspond to the same account.

Connecting to the WebSocket⚓︎

    async with connect(WS_URL) as websocket:
        # Connect to WebSocket
        response = json.loads(await websocket.recv())
        uid = response['uid']
        print(f'Connected to {WS_URL} with uid {uid}')
    // Connect to WebSocket
    const websocket = new WebSocket(WS_URL);
    const uid = await new Promise((resolve) => {
        websocket.addEventListener('message', (event) => {
            const message = JSON.parse(event.data);
            resolve(message.uid);
        }, { once: true });
    });
    console.log(`Connected to ${WS_URL} with uid ${uid}`);

The code opens a WebSocket connection to the node's /ws endpoint. Upon connecting, the server sends a message containing a unique identifier (uid) that must be included in all subsequent subscription requests.

See the WebSocket reference for details on the connection protocol.

Subscribing to the Status Channel⚓︎

        # Subscribe to status channel
        channel = f'status/{MONITOR_ADDRESS}'
        await websocket.send(json.dumps(
            {'uid': uid, 'subscribe': channel}
        ))
        print('Subscribed to status channel')
    // Subscribe to status channel
    const channel = `status/${MONITOR_ADDRESS}`;
    websocket.send(JSON.stringify({ uid, subscribe: channel }));
    console.log('Subscribed to status channel');

The code subscribes to the status/{address} WS channel scoped to the monitored address. This channel notifies whenever a transaction involving the address is rejected by the network, providing the error code and the transaction hash.

Building and Signing an Invalid Transfer Transaction⚓︎

        # Build a transfer transaction with a non-existent mosaic
        with urllib.request.urlopen(f'{NODE_URL}/node/time') as resp:
            time_json = json.loads(resp.read().decode())
            timestamp = NetworkTimestamp(int(
                time_json['communicationTimestamps']['receiveTimestamp']))

        with urllib.request.urlopen(
            f'{NODE_URL}/network/fees/transaction'
        ) as resp:
            fee_json = json.loads(resp.read().decode())
            fee_mult = max(
                fee_json['medianFeeMultiplier'],
                fee_json['minFeeMultiplier'])

        transaction = facade.transaction_factory.create({
            'type': 'transfer_transaction_v1',
            'signer_public_key': signer_key_pair.public_key,
            'deadline': timestamp.add_hours(2).timestamp,
            'recipient_address': MONITOR_ADDRESS,
            'mosaics': [{
                'mosaic_id': generate_mosaic_alias_id('symbol.unknown'),
                'amount': 1
            }],
        })
        transaction.fee = Amount(fee_mult * transaction.size)

        signature = facade.sign_transaction(signer_key_pair, transaction)
        json_payload = facade.transaction_factory.attach_signature(
            transaction, signature)
        transaction_hash = str(facade.hash_transaction(transaction))
    // Build a transfer transaction with a non-existent mosaic
    const timeResponse = await fetch(`${NODE_URL}/node/time`);
    const timeJSON = await timeResponse.json();
    const timestamp = new NetworkTimestamp(
        timeJSON.communicationTimestamps.receiveTimestamp);

    const feeResponse = await fetch(
        `${NODE_URL}/network/fees/transaction`);
    const feeJSON = await feeResponse.json();
    const feeMult = Math.max(
        feeJSON.medianFeeMultiplier, feeJSON.minFeeMultiplier);

    const transaction = facade.transactionFactory.create({
        type: 'transfer_transaction_v1',
        signerPublicKey: signerKeyPair.publicKey.toString(),
        deadline: timestamp.addHours(2).timestamp,
        recipientAddress: MONITOR_ADDRESS,
        mosaics: [{
            mosaicId: generateMosaicAliasId('symbol.unknown'),
            amount: 1n
        }],
    });
    transaction.fee = new models.Amount(feeMult * transaction.size);

    const signature = facade.signTransaction(signerKeyPair, transaction);
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction, signature);
    const transactionHash = facade.hashTransaction(transaction).toString();

This tutorial builds a Transfer Transaction sent to the monitored address, including a mosaic with the alias symbol.unknown. Since this mosaic does not exist on the network, the transaction will be rejected.

The transaction is built as usual: fetching the network time and fee multiplier, creating the transaction descriptor, and signing it. The hash is computed locally so it can be matched against the incoming WebSocket error message.

Announcing and Waiting for the Error⚓︎

        announce_request = urllib.request.Request(
            f'{NODE_URL}/transactions',
            data=json_payload.encode(),
            headers={'Content-Type': 'application/json'},
            method='PUT'
        )
        with urllib.request.urlopen(announce_request) as resp:
            resp.read()
        print(f'Announced transaction {transaction_hash[:16]}...')

        # Wait for error via WebSocket
        async for raw_message in websocket:
            msg = json.loads(raw_message)
            tx_hash = msg['data']['hash']
            code = msg['data']['code']
            print(
                f'Transaction {tx_hash[:16]}... '
                f'rejected with code: {code}'
            )

            if tx_hash == transaction_hash:
                break
    const rejected = new Promise((resolve) => {
        websocket.addEventListener('message', (event) => {
            const msg = JSON.parse(event.data);
            const txHash = msg.data.hash;
            const code = msg.data.code;
            console.log(
                `Transaction ${txHash.substring(0, 16)}... `
                + `rejected with code: ${code}`);

            if (txHash === transactionHash) {
                resolve();
            }
        });
    });
    await fetch(`${NODE_URL}/transactions`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: jsonPayload
    });
    console.log(
        'Announced transaction '
        + `${transactionHash.substring(0, 16)}...`);

    // Wait for error via WebSocket
    await rejected;

The code announces the transaction and then listens for incoming messages. Each message follows the TransactionStatusDTO schema and contains:

  • hash: The hash of the rejected transaction.
  • code: The error code explaining why the transaction was rejected. See the TransactionStatusEnum schema for all possible values.

When the received hash matches the announced transaction, the program prints the error code and exits.

Unsubscribing from the Channel⚓︎

        # Unsubscribe before closing
        await websocket.send(json.dumps(
            {'uid': uid, 'unsubscribe': channel}
        ))
        print('Unsubscribed from status channel')
    // Unsubscribe before closing
    websocket.send(JSON.stringify({ uid, unsubscribe: channel }));
    console.log('Unsubscribed from status channel');
    websocket.close();

After receiving the error, the code sends an unsubscribe message before closing the connection.

Output⚓︎

1
2
3
4
5
6
7
Using node https://reference.symboltest.net:3001
Monitoring address: TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I
Connected to wss://reference.symboltest.net:3001/ws with uid jI0YhF0bJflDsIO915kmWUlZZew=
Subscribed to status channel
Announced transaction E14B012D3D254EF2...
Transaction E14B012D3D254EF2... rejected with code: Failure_Core_Insufficient_Balance
Unsubscribed from status channel

The output shows:

  • Address (line 2): The monitored address.
  • Connection (line 3): The WebSocket connection is established and the server returns a unique uid.
  • Subscription (line 4): The status channel is subscribed.
  • Announcement (line 5): The transaction is announced and its hash is printed.
  • Error (line 6): The network rejects the transaction with Failure_Core_Insufficient_Balance because the sender does not hold the requested mosaic.
  • Unsubscribe (line 7): The code unsubscribes from the status channel.

Conclusion⚓︎

This tutorial showed how to:

Step Related documentation
Subscribe to status channel status/{address} WS
Trigger a rejection Transfer Transaction
Handle error messages TransactionStatusDTO