Skip to content

Sending Messages with Transfer Transactions⚓︎

INTERMEDIATE

Transfer transactions can include an optional message field, which allows attaching up to 1,024 bytes of data to the transaction. Messages can be sent as plain text or encrypted using the recipient's public key, ensuring only the intended recipient can read them.

This tutorial shows how to send both plain and encrypted messages and how to decode received messages.

Prerequisites⚓︎

Before you start, make sure to:

Additionally, check the Transfer transaction tutorial to understand how fee calculation, network time, and transaction confirmation work.

Full Code⚓︎

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

import json
import os
import time
import urllib.error
import urllib.request
from binascii import hexlify

from symbolchain.CryptoTypes import PrivateKey, PublicKey
from symbolchain.facade.SymbolFacade import SymbolFacade
from symbolchain.symbol.MessageEncoder import MessageEncoder

# Configuration
NODE_URL = os.getenv("NODE_URL", "https://reference.symboltest.net:3001")
print(f"Using node {NODE_URL}")


# Helper function to poll for confirmed transaction
def retrieve_confirmed_transaction(hash_value, label):
    print(f"Polling for {label} confirmation...")
    attempts = 0
    max_attempts = 60

    while attempts < max_attempts:
        try:
            url = f"{NODE_URL}/transactions/confirmed/{hash_value}"
            with urllib.request.urlopen(url) as transaction_confirmed:
                print(f"  {label} confirmed!")
                return json.loads(transaction_confirmed.read().decode())
        except urllib.error.HTTPError:
            # Transaction not yet confirmed
            pass
        attempts += 1
        time.sleep(2)

    raise TimeoutError(
        f"{label} not confirmed after {max_attempts} attempts"
    )


# Set up sender and recipient accounts
facade = SymbolFacade("testnet")

sender_private_key_string = os.getenv(
    "SENDER_PRIVATE_KEY",
    "0000000000000000000000000000000000000000000000000000000000000000",
)
sender_key_pair = facade.KeyPair(
    PrivateKey(sender_private_key_string)
)
sender_address = facade.network.public_key_to_address(
    sender_key_pair.public_key
)

recipient_private_key_string = os.getenv(
    "RECIPIENT_PRIVATE_KEY",
    "1111111111111111111111111111111111111111111111111111111111111111",
)
recipient_key_pair = facade.KeyPair(
    PrivateKey(recipient_private_key_string)
)
recipient_address = facade.network.public_key_to_address(
    recipient_key_pair.public_key
)

print(f"Sender address: {sender_address}")
print(f"Recipient address: {recipient_address}\n")

# 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_multiplier = response_json["medianFeeMultiplier"]
    minimum_multiplier = response_json["minFeeMultiplier"]
    fee_multiplier = max(median_multiplier, minimum_multiplier)
    print(f"  Fee multiplier: {fee_multiplier}\n")

# --- PLAIN TEXT MESSAGE ---
print("==> Sending Plain Text Message")

# Create a plain text message
plain_message = "Hello, Symbol!".encode("utf-8")
print(f"Plain message: {plain_message.decode('utf-8')}")

# Build transfer transaction with plain message
plain_transaction = facade.create_transaction_from_descriptor(
    {
        "type": "transfer_transaction_v1",
        "recipient_address": recipient_address,
        "mosaics": [],
        "message": plain_message,
    },
    sender_key_pair.public_key,
    fee_multiplier,
    2 * 60 * 60)

# Sign and announce the transaction
plain_signature = facade.sign_transaction(
    sender_key_pair, plain_transaction
)
plain_json_payload = facade.transaction_factory.attach_signature(
    plain_transaction, plain_signature
)
plain_transaction_hash = facade.hash_transaction(
    plain_transaction
)
print(f"Transaction hash: {plain_transaction_hash}")

plain_announce_request = urllib.request.Request(
    f"{NODE_URL}/transactions",
    data=plain_json_payload.encode("utf-8"),
    headers={"Content-Type": "application/json"},
    method="PUT",
)
with urllib.request.urlopen(plain_announce_request) as response:
    print("Plain message transaction announced\n")

# --- RECEIVING PLAIN TEXT MESSAGE ---
print("<== Receiving Plain Text Message")

# Wait for confirmation
plain_tx_data = retrieve_confirmed_transaction(
    plain_transaction_hash, "Plain message transaction"
)

# Decode plain message from confirmed transaction
received_plain_message = bytes.fromhex(
    plain_tx_data["transaction"]["message"]
)
print(
    f'Received plain message: {received_plain_message.decode("utf-8")}\n'
)

# --- ENCRYPTED MESSAGE ---
print("==> Sending Encrypted Message")

# Create a message encoder with sender's key pair
sender_message_encoder = MessageEncoder(sender_key_pair)

# Encrypt the message using recipient's public key
secret_message = "This is a secret message!".encode("utf-8")
encrypted_payload = sender_message_encoder.encode(
    recipient_key_pair.public_key, secret_message
)
print(f"Original message: {secret_message.decode('utf-8')}")
print(f"Encrypted payload: {hexlify(encrypted_payload).decode("utf-8")}")

# Build transfer transaction with encrypted message
encrypted_transaction = facade.create_transaction_from_descriptor(
    {
        "type": "transfer_transaction_v1",
        "recipient_address": recipient_address,
        "mosaics": [],
        "message": encrypted_payload,
    },
    sender_key_pair.public_key,
    fee_multiplier,
    2 * 60 * 60)

# Sign and announce the transaction
encrypted_signature = facade.sign_transaction(
    sender_key_pair, encrypted_transaction
)
encrypted_json_payload = facade.transaction_factory.attach_signature(
    encrypted_transaction, encrypted_signature
)
encrypted_transaction_hash = facade.hash_transaction(
    encrypted_transaction
)
print(f"Transaction hash: {encrypted_transaction_hash}")

encrypted_announce_request = urllib.request.Request(
    f"{NODE_URL}/transactions",
    data=encrypted_json_payload.encode("utf-8"),
    headers={"Content-Type": "application/json"},
    method="PUT",
)
with urllib.request.urlopen(encrypted_announce_request) as response:
    print("Encrypted message transaction announced\n")

# --- RECEIVING ENCRYPTED MESSAGE ---
print("<== Receiving Encrypted Message")

# Wait for confirmation
encrypted_tx_data = retrieve_confirmed_transaction(
    encrypted_transaction_hash, "Encrypted message transaction"
)

# Decode encrypted message using recipient's private key
recipient_message_encoder = MessageEncoder(recipient_key_pair)
received_encrypted_message = bytes.fromhex(
    encrypted_tx_data["transaction"]["message"]
)

# Get sender's public key from the transaction
sender_public_key_from_tx = PublicKey(
    encrypted_tx_data["transaction"]["signerPublicKey"]
)

(is_decoded, decrypted_message) = recipient_message_encoder.try_decode(
    sender_public_key_from_tx, received_encrypted_message
)

if is_decoded:
    message_text = decrypted_message.decode("utf-8")
    print(f"Recipient decrypted message: {message_text}")
else:
    print("Recipient failed to decrypt message")

Download source

import { PrivateKey, PublicKey } from 'symbol-sdk';
import {
    MessageEncoder,
    SymbolFacade,
    descriptors
} from 'symbol-sdk/symbol';

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

// Helper function to poll for confirmed transaction
async function retrieveConfirmedTransaction(hash, label) {
    console.log(`Polling for ${label} confirmation...`);
    let attempts = 0;
    const maxAttempts = 60;

    while (attempts < maxAttempts) {
        try {
            const transaction_confirmed = await fetch(
                `${NODE_URL}/transactions/confirmed/${hash}`);
            if (transaction_confirmed.ok) {
                console.log(`  ${label} confirmed!`);
                return await transaction_confirmed.json();
            }
        } catch {
            // Transaction not yet confirmed
        }
        attempts++;
        await new Promise(resolve => { setTimeout(resolve, 2000); });
    }

    throw new Error(
        `${label} not confirmed after ${maxAttempts} attempts`);
}

// Set up sender and recipient accounts
const facade = new SymbolFacade('testnet');

const senderPrivateKeyString = process.env.SENDER_PRIVATE_KEY ||
    '0000000000000000000000000000000000000000000000000000000000000000';
const senderKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(senderPrivateKeyString));
const senderAddress = facade.network.publicKeyToAddress(
    senderKeyPair.publicKey);

const recipientPrivateKeyString = process.env.RECIPIENT_PRIVATE_KEY ||
    '1111111111111111111111111111111111111111111111111111111111111111';
const recipientKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(recipientPrivateKeyString));
const recipientAddress = facade.network.publicKeyToAddress(
    recipientKeyPair.publicKey);

console.log('Sender address:', senderAddress.toString());
console.log('Recipient address:', recipientAddress.toString(), '\n');

// 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 medianMultiplier = feeJSON.medianFeeMultiplier;
const minimumMultiplier = feeJSON.minFeeMultiplier;
const feeMultiplier = Math.max(medianMultiplier, minimumMultiplier);
console.log('  Fee multiplier:', feeMultiplier, '\n');

// --- PLAIN TEXT MESSAGE ---
console.log('==> Sending Plain Text Message');

// Create a plain text message
const plainMessage = new TextEncoder().encode('Hello, Symbol!');
console.log('Plain message:',
    new TextDecoder().decode(plainMessage));

// Build transfer transaction with plain message
const plainTransaction = facade.createTransactionFromTypedDescriptor(
    new descriptors.TransferTransactionV1Descriptor(
        recipientAddress,
        [],
        plainMessage
    ),
    senderKeyPair.publicKey,
    feeMultiplier,
    2 * 60 * 60);

// Sign and announce the transaction
const plainSignature = facade.signTransaction(
    senderKeyPair, plainTransaction);
const plainJsonPayload = facade.transactionFactory.static
    .attachSignature(plainTransaction, plainSignature);
const plainTransactionHash = facade.hashTransaction(
    plainTransaction).toString();
console.log('Transaction hash:', plainTransactionHash);

await fetch(`${NODE_URL}/transactions`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: plainJsonPayload
});
console.log('Plain message transaction announced\n');

// --- RECEIVING PLAIN TEXT MESSAGE ---
console.log('<== Receiving Plain Text Message');

// Wait for confirmation
const plainTxData = await retrieveConfirmedTransaction(
    plainTransactionHash, 'Plain message transaction');

// Decode plain message from confirmed transaction
const receivedPlainMessage = Buffer.from(
    plainTxData.transaction.message, 'hex');
console.log('Received plain message:',
    new TextDecoder().decode(receivedPlainMessage), '\n');

// --- ENCRYPTED MESSAGE ---
console.log('==> Sending Encrypted Message');

// Create a message encoder with sender's key pair
const senderMessageEncoder = new MessageEncoder(senderKeyPair);

// Encrypt the message using recipient's public key
const secretMessage = new TextEncoder().encode(
    'This is a secret message!');
const encryptedPayload = senderMessageEncoder.encode(
    recipientKeyPair.publicKey, secretMessage
);
console.log(
    'Original message:', new TextDecoder().decode(secretMessage));
console.log('Encrypted payload:',
    Buffer.from(encryptedPayload).toString('hex'));

// Build transfer transaction with encrypted message
const encryptedTransaction = facade.createTransactionFromTypedDescriptor(
    new descriptors.TransferTransactionV1Descriptor(
        recipientAddress,
        [],
        encryptedPayload
    ),
    senderKeyPair.publicKey,
    feeMultiplier,
    2 * 60 * 60);

// Sign and announce the transaction
const encryptedSignature = facade.signTransaction(
    senderKeyPair, encryptedTransaction);
const encryptedJsonPayload = facade.transactionFactory.static
    .attachSignature(encryptedTransaction, encryptedSignature);
const encryptedTransactionHash = facade.hashTransaction(
    encryptedTransaction).toString();
console.log('Transaction hash:', encryptedTransactionHash);

await fetch(`${NODE_URL}/transactions`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: encryptedJsonPayload
});
console.log('Encrypted message transaction announced\n');

// --- RECEIVING ENCRYPTED MESSAGE ---
console.log('<== Receiving Encrypted Message');

// Wait for confirmation
const encryptedTxData = await retrieveConfirmedTransaction(
    encryptedTransactionHash, 'Encrypted message transaction');

// Decode encrypted message using recipient's private key
const recipientMessageEncoder = new MessageEncoder(recipientKeyPair);
const receivedEncryptedMessage = Buffer.from(
    encryptedTxData.transaction.message, 'hex');

// Get sender's public key from the transaction
const senderPublicKeyFromTx = new PublicKey(
    encryptedTxData.transaction.signerPublicKey);

const result = recipientMessageEncoder.tryDecode(
    senderPublicKeyFromTx, receivedEncryptedMessage);

if (result.isDecoded) {
    console.log('Recipient decrypted message:',
        new TextDecoder().decode(result.message));
} else {
    console.log('Recipient failed to decrypt message');
}

Download source

//JAVA 21+
//DEPS org.symbol:symbol-sdk:3.3.1

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.nio.charset.StandardCharsets;
import java.util.HexFormat;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import org.symbol.sdk.CryptoTypes;
import org.symbol.sdk.MessageEncoderResult;
import org.symbol.sdk.facade.SymbolFacade;
import org.symbol.sdk.symbol.Address;
import org.symbol.sdk.symbol.KeyPair;
import org.symbol.sdk.symbol.MessageEncoder;
import org.symbol.sdk.symbol.SymbolTransactionFactory;
import org.symbol.sdk.symbol.descriptors.*;
import org.symbol.sdk.symbol.models.Transaction;

public final class Messages {
    private static final ObjectMapper JSON_MAPPER = new ObjectMapper();

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

    // Configuration
    private final String nodeUrl = System.getenv().getOrDefault(
        "NODE_URL", "https://reference.symboltest.net:3001");

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

    // Helper function to poll for confirmed transaction
    private JsonNode retrieveConfirmedTransaction(
        final String hash,
        final String label
    ) throws IOException, InterruptedException {
        System.out.printf("Polling for %s confirmation...%n", label);
        int attempts = 0;
        final int maxAttempts = 60;

        while (attempts < maxAttempts) {
            try {
                final HttpRequest transactionConfirmed =
                    HttpRequest.newBuilder(URI.create(
                        nodeUrl + "/transactions/confirmed/" + hash))
                        .GET()
                        .build();
                final HttpResponse<String> response = HTTP_CLIENT.send(
                    transactionConfirmed, BodyHandlers.ofString());
                if (response.statusCode() / 100 == 2) {
                    System.out.printf("  %s confirmed!%n", label);
                    return JSON_MAPPER.readTree(response.body());
                }
            } catch (final IOException ex) {
                // Transaction not yet confirmed
            }
            ++attempts;
            Thread.sleep(2000);
        }

        throw new IOException(String.format(
            "%s not confirmed after %d attempts", label, maxAttempts));
    }

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

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

        // Set up sender and recipient accounts
        final String senderPrivateKeyString = System.getenv().getOrDefault(
            "SENDER_PRIVATE_KEY", "0".repeat(64));
        final KeyPair senderKeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(senderPrivateKeyString));
        final Address senderAddress = facade.network.publicKeyToAddress(
            senderKeyPair.getPublicKey());

        final String recipientPrivateKeyString = System.getenv()
            .getOrDefault("RECIPIENT_PRIVATE_KEY", "1".repeat(64));
        final KeyPair recipientKeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(recipientPrivateKeyString));
        final Address recipientAddress = facade.network.publicKeyToAddress(
            recipientKeyPair.getPublicKey());

        System.out.printf("Sender address: %s%n", senderAddress);
        System.out.printf("Recipient address: %s%n%n", recipientAddress);

        // Fetch recommended fees
        final String feePath = "/network/fees/transaction";
        System.out.printf(
            "Fetching recommended fees from %s%n", feePath);
        final HttpRequest feeRequest = HttpRequest.newBuilder(
            URI.create(nodeUrl + feePath)).GET().build();
        final HttpResponse<String> feeResponse = HTTP_CLIENT.send(
            feeRequest, BodyHandlers.ofString());
        final JsonNode feeJSON = JSON_MAPPER.readTree(
            feeResponse.body());
        final long medianMultiplier =
            feeJSON.get("medianFeeMultiplier").asLong();
        final long minimumMultiplier =
            feeJSON.get("minFeeMultiplier").asLong();
        final long feeMultiplier = Math.max(
            medianMultiplier, minimumMultiplier);
        System.out.printf("  Fee multiplier: %d%n%n", feeMultiplier);

        // --- PLAIN TEXT MESSAGE ---
        System.out.println("==> Sending Plain Text Message");

        // Create a plain text message
        final byte[] plainMessage =
            "Hello, Symbol!".getBytes(StandardCharsets.UTF_8);
        System.out.printf("Plain message: %s%n",
            new String(plainMessage, StandardCharsets.UTF_8));

        // Build transfer transaction with plain message
        final Transaction plainTransaction =
            facade.createTransactionFromTypedDescriptor(
                new TransferTransactionV1Descriptor(
                    recipientAddress,
                    null,
                    plainMessage),
                senderKeyPair.getPublicKey(),
                feeMultiplier,
                2 * 60 * 60);

        // Sign and announce the transaction
        final CryptoTypes.Signature plainSignature =
            facade.signTransaction(senderKeyPair, plainTransaction);
        final String plainJsonPayload = SymbolTransactionFactory
            .attachSignature(plainTransaction, plainSignature);
        final String plainTransactionHash = facade.hashTransaction(
            plainTransaction).toString();
        System.out.printf("Transaction hash: %s%n", plainTransactionHash);

        put("/transactions", plainJsonPayload);
        System.out.println("Plain message transaction announced\n");

        // --- RECEIVING PLAIN TEXT MESSAGE ---

        System.out.println("<== Receiving Plain Text Message");

        // Wait for confirmation
        final JsonNode plainTxData = retrieveConfirmedTransaction(
            plainTransactionHash, "Plain message transaction");

        // Decode plain message from confirmed transaction
        final byte[] receivedPlainMessage = HexFormat.of().parseHex(
            plainTxData.get("transaction").get("message").asText());
        System.out.printf("Received plain message: %s%n%n",
            new String(receivedPlainMessage, StandardCharsets.UTF_8));

        // --- ENCRYPTED MESSAGE ---
        System.out.println("==> Sending Encrypted Message");

        // Create a message encoder with sender's key pair
        final MessageEncoder senderMessageEncoder = new MessageEncoder(
            senderKeyPair);

        // Encrypt the message using recipient's public key
        final byte[] secretMessage =
            "This is a secret message!".getBytes(StandardCharsets.UTF_8);
        final byte[] encryptedPayload = senderMessageEncoder.encode(
            recipientKeyPair.getPublicKey(), secretMessage
        );
        System.out.printf("Original message: %s%n",
            new String(secretMessage, StandardCharsets.UTF_8));
        System.out.printf("Encrypted payload: %s%n",
            HexFormat.of().formatHex(encryptedPayload));

        // Build transfer transaction with encrypted message
        final Transaction encryptedTransaction =
            facade.createTransactionFromTypedDescriptor(
                new TransferTransactionV1Descriptor(
                    recipientAddress,
                    null,
                    encryptedPayload),
                senderKeyPair.getPublicKey(),
                feeMultiplier,
                2 * 60 * 60);

        // Sign and announce the transaction
        final CryptoTypes.Signature encryptedSignature =
            facade.signTransaction(senderKeyPair, encryptedTransaction);
        final String encryptedJsonPayload = SymbolTransactionFactory
            .attachSignature(encryptedTransaction, encryptedSignature);
        final String encryptedTransactionHash = facade.hashTransaction(
            encryptedTransaction).toString();
        System.out.printf("Transaction hash: %s%n",
            encryptedTransactionHash);

        put("/transactions", encryptedJsonPayload);
        System.out.println("Encrypted message transaction announced\n");

        // --- RECEIVING ENCRYPTED MESSAGE ---
        System.out.println("<== Receiving Encrypted Message");

        // Wait for confirmation
        final JsonNode encryptedTxData = retrieveConfirmedTransaction(
            encryptedTransactionHash, "Encrypted message transaction");

        // Decode encrypted message using recipient's private key
        final MessageEncoder recipientMessageEncoder = new MessageEncoder(
            recipientKeyPair);
        final byte[] receivedEncryptedMessage = HexFormat.of().parseHex(
            encryptedTxData.get("transaction").get("message").asText());

        // Get sender's public key from the transaction
        final CryptoTypes.PublicKey senderPublicKeyFromTx =
            new CryptoTypes.PublicKey(encryptedTxData.get("transaction")
                .get("signerPublicKey").asText());

        final MessageEncoderResult result =
            recipientMessageEncoder.tryDecode(
                senderPublicKeyFromTx, receivedEncryptedMessage);

        if (result.isDecoded()) {
            System.out.printf("Recipient decrypted message: %s%n",
                new String((byte[]) result.message(),
                    StandardCharsets.UTF_8));
        } else {
            System.out.println("Recipient failed to decrypt message");
        }
    }

    private void put(
        final String path,
        final String payload
    ) throws IOException, InterruptedException {
        final HttpRequest request = HttpRequest.newBuilder(
            URI.create(nodeUrl + path))
            .header("Content-Type", "application/json")
            .PUT(HttpRequest.BodyPublishers.ofString(payload))
            .build();
        HTTP_CLIENT.send(request, BodyHandlers.ofString());
    }
}

Download source

Code Explanation⚓︎

This tutorial focuses on the message-specific aspects of transfer transactions. The parts about fetching fees, creating, and announcing transactions have been explained in the Transfer Transaction tutorial and are skipped here for brevity.

Setting Up Accounts⚓︎

# Set up sender and recipient accounts
facade = SymbolFacade("testnet")

sender_private_key_string = os.getenv(
    "SENDER_PRIVATE_KEY",
    "0000000000000000000000000000000000000000000000000000000000000000",
)
sender_key_pair = facade.KeyPair(
    PrivateKey(sender_private_key_string)
)
sender_address = facade.network.public_key_to_address(
    sender_key_pair.public_key
)

recipient_private_key_string = os.getenv(
    "RECIPIENT_PRIVATE_KEY",
    "1111111111111111111111111111111111111111111111111111111111111111",
)
recipient_key_pair = facade.KeyPair(
    PrivateKey(recipient_private_key_string)
)
recipient_address = facade.network.public_key_to_address(
    recipient_key_pair.public_key
)

print(f"Sender address: {sender_address}")
print(f"Recipient address: {recipient_address}\n")
// Set up sender and recipient accounts
const facade = new SymbolFacade('testnet');

const senderPrivateKeyString = process.env.SENDER_PRIVATE_KEY ||
    '0000000000000000000000000000000000000000000000000000000000000000';
const senderKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(senderPrivateKeyString));
const senderAddress = facade.network.publicKeyToAddress(
    senderKeyPair.publicKey);

const recipientPrivateKeyString = process.env.RECIPIENT_PRIVATE_KEY ||
    '1111111111111111111111111111111111111111111111111111111111111111';
const recipientKeyPair = new SymbolFacade.KeyPair(
    new PrivateKey(recipientPrivateKeyString));
const recipientAddress = facade.network.publicKeyToAddress(
    recipientKeyPair.publicKey);

console.log('Sender address:', senderAddress.toString());
console.log('Recipient address:', recipientAddress.toString(), '\n');
        // Set up sender and recipient accounts
        final String senderPrivateKeyString = System.getenv().getOrDefault(
            "SENDER_PRIVATE_KEY", "0".repeat(64));
        final KeyPair senderKeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(senderPrivateKeyString));
        final Address senderAddress = facade.network.publicKeyToAddress(
            senderKeyPair.getPublicKey());

        final String recipientPrivateKeyString = System.getenv()
            .getOrDefault("RECIPIENT_PRIVATE_KEY", "1".repeat(64));
        final KeyPair recipientKeyPair = new KeyPair(
            new CryptoTypes.PrivateKey(recipientPrivateKeyString));
        final Address recipientAddress = facade.network.publicKeyToAddress(
            recipientKeyPair.getPublicKey());

        System.out.printf("Sender address: %s%n", senderAddress);
        System.out.printf("Recipient address: %s%n%n", recipientAddress);

To send a message, you need the sender's private key and the recipient's address. To encrypt a message, you additionally need the recipient's public key.

This tutorial uses two accounts (sender and recipient) to demonstrate both sending and receiving plain and encrypted messages. The snippet reads their private keys from the SENDER_PRIVATE_KEY and RECIPIENT_PRIVATE_KEY environment variables, which default to test keys if not set. The recipient's public key and address are derived from their private key.

Retrieving public keys

When only the address is known, you can retrieve the public key from the network using the /accounts/{accountId} GET endpoint. An account's public key becomes available only after it has broadcast at least one transaction.

Sending a Plain Text Message⚓︎

print("==> Sending Plain Text Message")

# Create a plain text message
plain_message = "Hello, Symbol!".encode("utf-8")
print(f"Plain message: {plain_message.decode('utf-8')}")

# Build transfer transaction with plain message
plain_transaction = facade.create_transaction_from_descriptor(
    {
        "type": "transfer_transaction_v1",
        "recipient_address": recipient_address,
        "mosaics": [],
        "message": plain_message,
    },
    sender_key_pair.public_key,
    fee_multiplier,
    2 * 60 * 60)
console.log('==> Sending Plain Text Message');

// Create a plain text message
const plainMessage = new TextEncoder().encode('Hello, Symbol!');
console.log('Plain message:',
    new TextDecoder().decode(plainMessage));

// Build transfer transaction with plain message
const plainTransaction = facade.createTransactionFromTypedDescriptor(
    new descriptors.TransferTransactionV1Descriptor(
        recipientAddress,
        [],
        plainMessage
    ),
    senderKeyPair.publicKey,
    feeMultiplier,
    2 * 60 * 60);
        System.out.println("==> Sending Plain Text Message");

        // Create a plain text message
        final byte[] plainMessage =
            "Hello, Symbol!".getBytes(StandardCharsets.UTF_8);
        System.out.printf("Plain message: %s%n",
            new String(plainMessage, StandardCharsets.UTF_8));

        // Build transfer transaction with plain message
        final Transaction plainTransaction =
            facade.createTransactionFromTypedDescriptor(
                new TransferTransactionV1Descriptor(
                    recipientAddress,
                    null,
                    plainMessage),
                senderKeyPair.getPublicKey(),
                feeMultiplier,
                2 * 60 * 60);

You can combine mosaic transfers with messages by including both the mosaics and message fields in the transaction descriptor.

The transaction is then signed and announced following the same process as in the Transfer Transaction tutorial.

Message constraints:

  • Maximum size: 1,024 bytes (the network rejects larger messages).
  • Encoding: UTF-8 by convention, though the protocol does not enforce a standard.
  • Privacy: All messages are publicly visible on the blockchain unless encrypted.

Handling larger data

For applications requiring more than 1,024 bytes of data, common approaches include:

  • On-chain storage: Split the data across multiple transactions within an aggregate transaction, allowing you to keep everything on the blockchain.
  • Off-chain storage: Store the data off-chain and include a hash and a reference in the message field. The hash verifies data integrity while the reference enables retrieval.

Receiving a Plain Text Message⚓︎

print("<== Receiving Plain Text Message")

# Wait for confirmation
plain_tx_data = retrieve_confirmed_transaction(
    plain_transaction_hash, "Plain message transaction"
)

# Decode plain message from confirmed transaction
received_plain_message = bytes.fromhex(
    plain_tx_data["transaction"]["message"]
)
print(
    f'Received plain message: {received_plain_message.decode("utf-8")}\n'
)
console.log('<== Receiving Plain Text Message');

// Wait for confirmation
const plainTxData = await retrieveConfirmedTransaction(
    plainTransactionHash, 'Plain message transaction');

// Decode plain message from confirmed transaction
const receivedPlainMessage = Buffer.from(
    plainTxData.transaction.message, 'hex');
console.log('Received plain message:',
    new TextDecoder().decode(receivedPlainMessage), '\n');
        System.out.println("<== Receiving Plain Text Message");

        // Wait for confirmation
        final JsonNode plainTxData = retrieveConfirmedTransaction(
            plainTransactionHash, "Plain message transaction");

        // Decode plain message from confirmed transaction
        final byte[] receivedPlainMessage = HexFormat.of().parseHex(
            plainTxData.get("transaction").get("message").asText());
        System.out.printf("Received plain message: %s%n%n",
            new String(receivedPlainMessage, StandardCharsets.UTF_8));

After announcing the transaction, the helper function polls the /transactions/confirmed/{transactionId} GET endpoint until the transaction is confirmed.

The confirmed transaction contains the message as a hex string. To retrieve the original message, it converts the hex string to bytes and decodes it as UTF-8.

Sending an Encrypted Message⚓︎

print("==> Sending Encrypted Message")

# Create a message encoder with sender's key pair
sender_message_encoder = MessageEncoder(sender_key_pair)

# Encrypt the message using recipient's public key
secret_message = "This is a secret message!".encode("utf-8")
encrypted_payload = sender_message_encoder.encode(
    recipient_key_pair.public_key, secret_message
)
print(f"Original message: {secret_message.decode('utf-8')}")
print(f"Encrypted payload: {hexlify(encrypted_payload).decode("utf-8")}")

# Build transfer transaction with encrypted message
encrypted_transaction = facade.create_transaction_from_descriptor(
    {
        "type": "transfer_transaction_v1",
        "recipient_address": recipient_address,
        "mosaics": [],
        "message": encrypted_payload,
    },
    sender_key_pair.public_key,
    fee_multiplier,
    2 * 60 * 60)
console.log('==> Sending Encrypted Message');

// Create a message encoder with sender's key pair
const senderMessageEncoder = new MessageEncoder(senderKeyPair);

// Encrypt the message using recipient's public key
const secretMessage = new TextEncoder().encode(
    'This is a secret message!');
const encryptedPayload = senderMessageEncoder.encode(
    recipientKeyPair.publicKey, secretMessage
);
console.log(
    'Original message:', new TextDecoder().decode(secretMessage));
console.log('Encrypted payload:',
    Buffer.from(encryptedPayload).toString('hex'));

// Build transfer transaction with encrypted message
const encryptedTransaction = facade.createTransactionFromTypedDescriptor(
    new descriptors.TransferTransactionV1Descriptor(
        recipientAddress,
        [],
        encryptedPayload
    ),
    senderKeyPair.publicKey,
    feeMultiplier,
    2 * 60 * 60);
        System.out.println("==> Sending Encrypted Message");

        // Create a message encoder with sender's key pair
        final MessageEncoder senderMessageEncoder = new MessageEncoder(
            senderKeyPair);

        // Encrypt the message using recipient's public key
        final byte[] secretMessage =
            "This is a secret message!".getBytes(StandardCharsets.UTF_8);
        final byte[] encryptedPayload = senderMessageEncoder.encode(
            recipientKeyPair.getPublicKey(), secretMessage
        );
        System.out.printf("Original message: %s%n",
            new String(secretMessage, StandardCharsets.UTF_8));
        System.out.printf("Encrypted payload: %s%n",
            HexFormat.of().formatHex(encryptedPayload));

        // Build transfer transaction with encrypted message
        final Transaction encryptedTransaction =
            facade.createTransactionFromTypedDescriptor(
                new TransferTransactionV1Descriptor(
                    recipientAddress,
                    null,
                    encryptedPayload),
                senderKeyPair.getPublicKey(),
                feeMultiplier,
                2 * 60 * 60);

Encrypted messages provide confidentiality by protecting the message content using a shared secret derived from the sender's private key and the recipient's public key. Both the sender and recipient can decrypt the message using their own private key and the other party's public key.

The class handles message encryption:

  1. A is created with the sender's key pair.
  2. The message is encoded using the recipient's public key and the message bytes with .
  3. The encrypted payload is attached to the transaction's message field.

The transaction is then signed and announced following the same process as in the Transfer Transaction tutorial.

Message encryption is a convention

The Symbol protocol does not define a standard for message encryption. Sender and recipient must agree in advance on whether messages are encrypted and the cipher used. The class implements a widely adopted convention used by most wallets and applications.

For more details, see Optional Messages in the Textbook.

Receiving an Encrypted Message⚓︎

print("<== Receiving Encrypted Message")

# Wait for confirmation
encrypted_tx_data = retrieve_confirmed_transaction(
    encrypted_transaction_hash, "Encrypted message transaction"
)

# Decode encrypted message using recipient's private key
recipient_message_encoder = MessageEncoder(recipient_key_pair)
received_encrypted_message = bytes.fromhex(
    encrypted_tx_data["transaction"]["message"]
)

# Get sender's public key from the transaction
sender_public_key_from_tx = PublicKey(
    encrypted_tx_data["transaction"]["signerPublicKey"]
)

(is_decoded, decrypted_message) = recipient_message_encoder.try_decode(
    sender_public_key_from_tx, received_encrypted_message
)

if is_decoded:
    message_text = decrypted_message.decode("utf-8")
    print(f"Recipient decrypted message: {message_text}")
else:
    print("Recipient failed to decrypt message")
console.log('<== Receiving Encrypted Message');

// Wait for confirmation
const encryptedTxData = await retrieveConfirmedTransaction(
    encryptedTransactionHash, 'Encrypted message transaction');

// Decode encrypted message using recipient's private key
const recipientMessageEncoder = new MessageEncoder(recipientKeyPair);
const receivedEncryptedMessage = Buffer.from(
    encryptedTxData.transaction.message, 'hex');

// Get sender's public key from the transaction
const senderPublicKeyFromTx = new PublicKey(
    encryptedTxData.transaction.signerPublicKey);

const result = recipientMessageEncoder.tryDecode(
    senderPublicKeyFromTx, receivedEncryptedMessage);

if (result.isDecoded) {
    console.log('Recipient decrypted message:',
        new TextDecoder().decode(result.message));
} else {
    console.log('Recipient failed to decrypt message');
}
        System.out.println("<== Receiving Encrypted Message");

        // Wait for confirmation
        final JsonNode encryptedTxData = retrieveConfirmedTransaction(
            encryptedTransactionHash, "Encrypted message transaction");

        // Decode encrypted message using recipient's private key
        final MessageEncoder recipientMessageEncoder = new MessageEncoder(
            recipientKeyPair);
        final byte[] receivedEncryptedMessage = HexFormat.of().parseHex(
            encryptedTxData.get("transaction").get("message").asText());

        // Get sender's public key from the transaction
        final CryptoTypes.PublicKey senderPublicKeyFromTx =
            new CryptoTypes.PublicKey(encryptedTxData.get("transaction")
                .get("signerPublicKey").asText());

        final MessageEncoderResult result =
            recipientMessageEncoder.tryDecode(
                senderPublicKeyFromTx, receivedEncryptedMessage);

        if (result.isDecoded()) {
            System.out.printf("Recipient decrypted message: %s%n",
                new String((byte[]) result.message(),
                    StandardCharsets.UTF_8));
        } else {
            System.out.println("Recipient failed to decrypt message");
        }

After announcing the encrypted message transaction, the helper function polls for confirmation.

To decrypt the message from the confirmed transaction, a is created with the recipient's key pair, then is called with the sender's public key (obtained from the transaction's signerPublicKey field) and the encrypted payload.

The method returns a tuple (is_decoded, message) indicating whether decryption was successful, and, if so, contains the original plaintext bytes, which still need to be decoded.

Decryption works both ways

Because the encryption uses a shared secret derived from both key pairs, the sender can also decrypt the message using their own private key and the recipient's public key. This allows both parties to verify the message content after it has been published on the blockchain.

If decryption fails, possible causes include:

  • The message was encrypted for a different recipient.
  • The message is corrupted or tampered with.
  • The message is plain text, not encrypted.
  • An incorrect public key was used for the other party.

Output⚓︎

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

Using node https://reference.symboltest.net:3001
Sender address: TCHBDENCLKEBILBPWP3JPB2XNY64OE7PYHHE32I
Recipient address: TCWYXKVYBMO4NBCUF3AXKJMXCGVSYQOS7ZG2TLI

Fetching recommended fees from /network/fees/transaction
  Fee multiplier: 100

==> Sending Plain Text Message
Plain message: Hello, Symbol!
Transaction hash: F30D4F2D20CDF46EC2A5D3D05E3A1059A18BD6D66C661A3DD66A7946612E857A
Plain message transaction announced

<== Receiving Plain Text Message
Polling for Plain message transaction confirmation...
  Plain message transaction confirmed!
Received plain message: Hello, Symbol!

==> Sending Encrypted Message
Original message: This is a secret message!
Encrypted payload: 01c1cc6085863447340555ba03d319e9b5e64c1619ff9fb6ed20a9e823f121056e0d96927df2a523df3f4e9663eb5981e2542528cb49
Transaction hash: 8E4582520E9BD1C4AC1E758BE49BFDF8719F9563676289D81A00E5532C8FA86D
Encrypted message transaction announced

<== Receiving Encrypted Message
Polling for Encrypted message transaction confirmation...
  Encrypted message transaction confirmed!
Recipient decrypted message: This is a secret message!

Some highlights from the output:

  • Plain message (line 9): The message attached to the first transaction. Because it is not encrypted, anyone inspecting the blockchain can read it.

  • Received plain message (line 16): The same message, recovered from the confirmed transaction by converting the hexadecimal payload back to UTF-8.

  • Original message (line 19): The secret message before encryption.

  • Encrypted payload (line 20): The result of encrypting the previous message with , shown as a hexadecimal string. This is what gets stored on the blockchain.

  • Recipient decrypted message (line 27): The original message, retrieved from the confirmed transaction and decrypted by using the recipient's private key and the sender's public key.

You can view the transactions on the Symbol Testnet Explorer by searching for the transaction hashes printed in the output.

The explorer cannot decrypt encrypted messages because it does not have access to the private keys.

Conclusion⚓︎

This tutorial showed how to:

Step Related documentation
Convert text into UTF-8 bytes TextEncoder (JS) and str.encode/bytes.decode (Python)
System methods, not part of the Symbol SDK
Include a message in a Transfer Transaction , TransferTransactionV1
Encrypt a message
Decrypt a message