Skip to content

Creating a Transfer Transaction⚓︎

Transfer transactions are the most basic type of Symbol transaction. They allow sending XYM or any other type of mosaic from one account to another.

This tutorial shows how to create, sign, and announce a transfer transaction, and then poll the transaction's status until it is confirmed.
Required transaction parameters, such as the current time and fees, are fetched from the network to use the most up-to-date values.

You should have completed the Hello World tutorial to understand how to run the tutorials.

Full Code⚓︎

import datetime
import json
import time
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

NODE_URL = 'https://001-sai-dual.symboltest.net:3001'
print(f'Using node {NODE_URL}')

SIGNER_PRIVATE_KEY = (
    'EDB671EB741BD676969D8A035271D1EE5E75DF33278083D877F23615EB839FEC')
signer_key_pair = SymbolFacade.KeyPair(PrivateKey(SIGNER_PRIVATE_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 transaction
    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':
            facade.network.public_key_to_address(
                signer_key_pair.public_key),
        'mosaics': [{
            'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
            'amount': 1_000_000 # 1 XYM
        }]
    })
    transaction.fee = Amount(fee_mult * transaction.size)

    # Sign transaction and generate final payload
    signature = facade.sign_transaction(signer_key_pair, transaction)
    json_payload = facade.transaction_factory.attach_signature(
        transaction, signature)
    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://001-sai-dual.symboltest.net:3001';
console.log('Using node', NODE_URL);

const SIGNER_PRIVATE_KEY =
    'EDB671EB741BD676969D8A035271D1EE5E75DF33278083D877F23615EB839FEC';
const signerKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(SIGNER_PRIVATE_KEY));

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 transaction
    const transaction = facade.transactionFactory.create({
        type: 'transfer_transaction_v1',
        signerPublicKey: signerKeyPair.publicKey.toString(),
        deadline: timestamp.addHours(2).timestamp,
        recipientAddress: facade.network.publicKeyToAddress(
            signerKeyPair.publicKey).toString(),
        mosaics: [{
            mosaicId: generateMosaicAliasId('symbol.xym'),
            amount: 1_000_000n // 1 XYM
        }]
    });
    transaction.fee = new models.Amount(feeMult * transaction.size);

    // Sign transaction and generate final payload
    const signature = facade.signTransaction(signerKeyPair, transaction);
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction, signature);
    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

The whole code is wrapped in a single try block to provide simple error handling, but applications will probably want to use more fine-grained control.

Code Explanation⚓︎

Fetching Network Time⚓︎

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

Transactions on Symbol must include a deadline, which defines how long the network should attempt to confirm the transaction before discarding it. Deadlines are expressed in absolute network time, so the first step is to fetch the current network time from a node.

What is the network time?

Symbol defines time as the number of seconds elapsed since the creation of its first block, known as the Nemesis block (or Genesis block, for the rest of blockchains).

All transaction deadlines and timestamps are calculated relative to this origin.

If you want to display timestamps in a more human-friendly way such as UTC, you need to add the timestamp of the Nemesis block, which you can retrieve from the network properties:

properties_path = '/network/properties'
print(f'Fetching network properties from {properties_path}')
with urllib.request.urlopen(f'{NODE_URL}{properties_path}') as response:
    response_json = json.loads(response.read().decode())
    epoch_adjustment = datetime.datetime.fromtimestamp(
        int(response_json['network']['epochAdjustment'].rstrip('s')))
    print(f'  Nemesis timestamp: {epoch_adjustment}')
const propertiesPath = '/network/properties';
console.log('Fetching network properties from', propertiesPath);
const propertiesResponse = await fetch(`${NODE_URL}${propertiesPath}`);
const propertiesJSON = await propertiesResponse.json();
const epochAdjustment = new Date(parseInt(
  propertiesJSON['network']['epochAdjustment']) * 1000);
console.log('  Nemesis timestamp:', epochAdjustment);

If a transaction's deadline is earlier than the current network time or more than two hours in the future, the transaction will be rejected. To avoid this, you need to know the current network time before constructing the transaction, using the /node/time endpoint.

However, applications do not need to query the network time before every transaction. It can be fetched once and then adjusted using the local system clock when needed. This provides a good balance between accuracy and performance.

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

Transactions on Symbol must pay a fee to incentivize nodes to include them in blocks. If the fee is too low, no node may include the transaction. If it is too high, the sender wastes funds. In addition, each node may enforce a minimum fee threshold for incoming transactions.

The optimal fee depends on the current state of the network, particularly the number of transactions being submitted and the fees they are offering. To support fee estimation, Symbol provides the /network/fees/transaction endpoint that returns a recommended fee multiplier based on recent transaction activity.

The final fee is calculated by multiplying the recommended multiplier by the transaction's size in bytes. This ensures that larger transactions pay proportionally more while smaller ones remain cost-effective.

Although applications can use a fixed fee for simplicity, it is more efficient to follow the network recommendation. As with network time, there is no need to query the multiplier for every transaction, but it should be refreshed regularly.

The snippet above takes the greater of the recommended multiplier (medianFeeMultiplier) and the node's minimum multiplier (minFeeMultiplier), and stores it for later use once the transaction size is known.

Building the Transaction⚓︎

    # Build the transaction
    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':
            facade.network.public_key_to_address(
                signer_key_pair.public_key),
        'mosaics': [{
            'mosaic_id': generate_mosaic_alias_id('symbol.xym'),
            'amount': 1_000_000 # 1 XYM
        }]
    })
    transaction.fee = Amount(fee_mult * transaction.size)
    // Build the transaction
    const transaction = facade.transactionFactory.create({
        type: 'transfer_transaction_v1',
        signerPublicKey: signerKeyPair.publicKey.toString(),
        deadline: timestamp.addHours(2).timestamp,
        recipientAddress: facade.network.publicKeyToAddress(
            signerKeyPair.publicKey).toString(),
        mosaics: [{
            mosaicId: generateMosaicAliasId('symbol.xym'),
            amount: 1_000_000n // 1 XYM
        }]
    });
    transaction.fee = new models.Amount(feeMult * transaction.size);

Use a TransferTransactionV1Descriptor to create this transaction in a type-safe way.
See the Typed Descriptors tutorial for details.

All required transaction properties must be provided when building the transfer transaction. The snippet includes the following fields:

  • Type: Transfer transactions use the type transfer_transaction_v1.

  • Signer public key: The signer is the account that will pay the fee. In a transfer transaction, it is also the source of the transferred mosaics.

  • Deadline: This value is set to two hours after the current network time, which is the maximum allowed deadline.

  • Recipient address: In this example, the recipient is the same as the sender, which is useful for demonstration but not terribly practical.

  • Mosaics: This is an array, because a transfer transaction can send multiple mosaics at once. Each entry includes a mosaic ID and an absolute amount.

    In the example, the mosaic ID for XYM is obtained using its alias, symbol.xym, which is easier to remember than the full hexadecimal ID.

    Absolute amounts depend on the mosaic's divisibility. For XYM, the divisibility is 6, so 1 XYM must be expressed as 1_000_000.

Note that the fee field is not set in the descriptor. Instead, the fee is calculated after the transaction is built, using the previously obtained multiplier and the transaction's size in bytes.

Signing and Serializing⚓︎

    # Sign transaction and generate final payload
    signature = facade.sign_transaction(signer_key_pair, transaction)
    json_payload = facade.transaction_factory.attach_signature(
        transaction, signature)
    print('Built transaction:')
    print(json.dumps(transaction.to_json(), indent=2))
    // Sign transaction and generate final payload
    const signature = facade.signTransaction(signerKeyPair, transaction);
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction, signature);
    console.log('Built transaction:');
    console.dir(transaction.toJson(), { colors: true });

Once the transaction is created, it must be signed with the signing account's private key. Signing ensures the transaction is authentic and authorized by the sender.

symbolchain.facade.SymbolFacade.SymbolFacade.sign_transaction returns a signature encoded as a hexadecimal string.

symbolchain.symbol.TransactionFactory.TransactionFactory.attach_signature adds the signature to the transaction and serializes it into a JSON payload ready to be submitted directly to a node for announcement.

Announcing the Transaction⚓︎

    # 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()}')
    // 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());

Announcing a transaction is a simple PUT request to the /transactions endpoint of any Symbol API node. As long as the payload is correctly formed, the request will succeed with an HTTP 200 response.

However, this response does not indicate that the transaction is valid or accepted by the network. Validation, fee checks, and other rules are applied asynchronously after the transaction is received.

To confirm that the transaction is actually accepted and included in a block, its status must be monitored separately, as shown in the next step.

Waiting for Confirmation⚓︎

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

Note

This step uses polling to check whether the transaction has been confirmed. Polling is used here for illustration purposes, but it is not the recommended approach for real applications.

A production-grade application should use WebSockets to receive confirmation events directly from the node. This provides a simpler and more responsive solution without the overhead of repeated API calls.

In addition, the logic for checking transaction status is reusable. It can be moved into a utility function or module, since it is needed after announcing every transaction.

The snippet above repeatedly queries the /transactionStatus endpoint using the hash of the submitted transaction. The response may take one of several forms:

  • An HTTP error, indicating that the node has not yet started processing the transaction.
  • A valid JSON object containing the transaction status.

If the status group is confirmed, the transaction has been accepted and included in a block.

If the status group is failed, the transaction has been rejected, for example, due to insufficient funds.

In any other case, the code waits one second and tries again, up to a maximum of 60 times.

Note that the code performs the first wait before performing the first status check. This gives the node some time to begin processing the transaction after it is announced.

Output⚓︎

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

Using node https://001-sai-dual.symboltest.net:3001
Fetching current network time from /node/time
  Network time: 78235462065n ms since nemesis
Fetching recommended fees from /network/fees/transaction
  Fee multiplier: 100
Built transaction:
{
  signature: '728D968E14F50EBB2496B560721E938629D6B4C1522B4A22DD659507B469C0EC5125485EBD38D48FBF351FF9DEC9CF3AFD7A5AFC5E945087E53173589B0B6B08',
  signerPublicKey: '87DA603E7BE5656C45692D5FC7F6D0EF8F24BB7A5C10ED5FDA8C5CFBC49FCBC8',
  version: 1,
  network: 152,
  type: 16724,
  fee: '17600',
  deadline: '78242662065',
  recipientAddress: '98F96BD2F803DE1EE39AACFC53A246F4F7A46901A5D0A53E',
  mosaics: [ { mosaicId: '16666583871264174062', amount: '1000000' } ],
  message: ''
}
Announcing transaction to /transactions
  Response: {"message":"packet 9 was pushed to the network via /transactions"}
Waiting for confirmation from /transactionStatus/260CD293E05C2853A967874BCF67FAB36FD331CE14925CA611B3877B99BB325D
  Transaction status: unknown | Cause: Not Found
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  Transaction status: unconfirmed
  Transaction status: confirmed
Transaction confirmed in 5 seconds

The number of status checks before confirmation can vary based on network conditions, and the initial unknown status may or may not appear, depending on how quickly the node begins processing the transaction.

To see the transaction from the network's perspective, you can visit the Symbol Testnet Explorer and search for the transaction hash.
The hash is printed in the line that says Waiting for confirmation from /transactionStatus/....
You should see the transaction move through the confirmation process in real time.

Alternatively, you can search for the signer_public_key to view the transaction in the history of the signer account.

Conclusion⚓︎

This tutorial showed how to:

Other transaction types follow the same general process.