From e0702a7f198e49fb2bf5d5ba970268797ff635a2 Mon Sep 17 00:00:00 2001 From: zero Date: Fri, 3 Jul 2026 14:47:06 +0100 Subject: [PATCH] [mkdocs] docs: add messages tutorial --- mkdocs/config/mkdocs.en.yml | 1 + .../pages/en/devbook/transactions/messages.md | 173 ++++++++++++++ .../devbook/transactions/messages.log | 27 +++ .../devbook/transactions/messages.mjs | 193 +++++++++++++++ .../snippets/devbook/transactions/messages.py | 224 ++++++++++++++++++ 5 files changed, 618 insertions(+) create mode 100644 mkdocs/pages/en/devbook/transactions/messages.md create mode 100644 mkdocs/snippets/devbook/transactions/messages.log create mode 100644 mkdocs/snippets/devbook/transactions/messages.mjs create mode 100644 mkdocs/snippets/devbook/transactions/messages.py diff --git a/mkdocs/config/mkdocs.en.yml b/mkdocs/config/mkdocs.en.yml index 2a1212b23..e22507d2a 100644 --- a/mkdocs/config/mkdocs.en.yml +++ b/mkdocs/config/mkdocs.en.yml @@ -89,6 +89,7 @@ nav: - Transactions: - devbook/transactions/transfer-xem.md - devbook/transactions/transfer-mosaics.md + - devbook/transactions/messages.md - devbook/transactions/monitoring-status.md - devbook/transactions/typed-descriptors.md - Mosaics: diff --git a/mkdocs/pages/en/devbook/transactions/messages.md b/mkdocs/pages/en/devbook/transactions/messages.md new file mode 100644 index 000000000..afd574235 --- /dev/null +++ b/mkdocs/pages/en/devbook/transactions/messages.md @@ -0,0 +1,173 @@ +--- +title: Messages +tutorial_level: intermediate +--- + +# Sending Messages with 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: + +* Set up your development environment. + See [Setting Up a Development Environment](../start/setup.md). +* Create an to send the transfer transaction, either + [from code](../accounts/create-from-private-key.md) or + [by using a wallet](../../userbook/wallet/create-account.md). +* Obtain to pay for the transaction fee. + See [Getting Testnet Funds from the Faucet](../accounts/testnet-faucet.md). + +Additionally, check the [Transfer transaction](./transfer-xem.md) tutorial to understand how fee +calculation, network time, and transaction confirmation work. + +## Full Code + +{% import 'tutorial.jinja2' as tutorial with context %} + +{{ tutorial.code_full_tagged('devbook/transactions/messages', ['py', 'js']) }} + +## Code Explanation + +This tutorial focuses on the message-specific aspects of transfer transactions. +The parts about fetching network time, calculating fees, and announcing transactions have been explained in the +[Transfer Transaction](./transfer-xem.md) tutorial and are skipped here for brevity. + +### Setting Up Accounts + +{{ tutorial.code_snippet_tagged('step-1') }} + +To send a message, you need the sender's and the recipient's . +To encrypt a message, you additionally need the recipient's . + +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. + +!!! note "Retrieving public keys" + + When only the address is known, you can retrieve the public key from the network using the + endpoint. + An account's public key becomes available only after it has broadcast at least one transaction. + +### Sending a Plain Text Message + +{{ tutorial.code_snippet_tagged('step-2') }} + +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 +[Sending XEM with a Transfer Transaction](./transfer-xem.md). + +**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. + +!!! tip "Handling larger data" + + For applications requiring more than 1,024 bytes of data, common approaches include: + + * **On-chain storage:** Split the data across multiple , 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 + +{{ tutorial.code_snippet_tagged('step-3') }} + +After announcing the transaction, the {{ tutorial.var('retrieve_confirmed_transaction') }} helper function polls the + 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 + +{{ tutorial.code_snippet_tagged('step-4') }} + +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 +[Sending XEM with a Transfer Transaction](./transfer-xem.md). + +!!! note "Message encryption is a convention" + + The NEM 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. + + implements **AES-GCM**, the convention used by most wallets and applications. + The class also provides for compatibility with legacy AES-CBC messages. + automatically detects and decodes both schemes. + + + For more details, see [Optional Messages](../../textbook/transfer_transactions.md#optional-message) in the Textbook. + +### Receiving an Encrypted Message + +{{ tutorial.code_snippet_tagged('step-5') }} + +After announcing the encrypted message transaction, the {{ tutorial.var('retrieve_confirmed_transaction') }} 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 +`signer` 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. + +!!! note "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. + +```text +--8<-- 'devbook/transactions/messages.log' +``` + +You can view the transactions on the [NEM testnet explorer](https://testnet.nem.fyi/) 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](#sending-a-plain-text-message) | `TextEncoder` (JS) and `str.encode`/`bytes.decode` (Python)
System methods, not part of the SDK | +| [Encrypt a message](#sending-an-encrypted-message) | | +| [Decrypt a message](#receiving-an-encrypted-message) | | diff --git a/mkdocs/snippets/devbook/transactions/messages.log b/mkdocs/snippets/devbook/transactions/messages.log new file mode 100644 index 000000000..7ffbda2c8 --- /dev/null +++ b/mkdocs/snippets/devbook/transactions/messages.log @@ -0,0 +1,27 @@ +Using node http://libertalia.nemtest.net:7890 +Sender address: TBONKWCOWBZYZB2I5JD3LSDBQVBYHB757VN3SKPP +Recipient address: TBULEAUG2CZQISUR442HWA6UAKGWIXHDABJVIPS4 + +Fetching current network time from /time-sync/network-time + Network time: 355248540 s since the nemesis block + +==> Sending Plain Text Message +Plain message: Hello, NEM! +Transaction hash: 2F8F20CAF8F6FA42ADE05ED85FED0B4D1DA88051972405C4AE2D181B01FB69C3 +Plain message transaction announced + +<== Receiving Plain Text Message +Polling for Plain message transaction confirmation... + Plain message transaction confirmed! +Received plain message: Hello, NEM! + +==> Sending Encrypted Message +Original message: This is a secret message! +Encrypted payload: 3fefcf6e4f1e5e2165f941a5c15ea66778f82eded50cfbde5fc27eba24f4580ff72fb9d0b45061747be0324a120dc357dd11351c09 +Transaction hash: 76604471D5A345E6F5CE20C65D618BC6F9A600F1DF515A696B2152E0E2D0B427 +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! diff --git a/mkdocs/snippets/devbook/transactions/messages.mjs b/mkdocs/snippets/devbook/transactions/messages.mjs new file mode 100644 index 000000000..41b8bf75b --- /dev/null +++ b/mkdocs/snippets/devbook/transactions/messages.mjs @@ -0,0 +1,193 @@ +import { PrivateKey, PublicKey } from 'symbol-sdk'; +import { + MessageEncoder, + NemFacade, + calculateTransactionFee, + models +} from 'symbol-sdk/nem'; + +// Configuration +const NODE_URL = process.env.NODE_URL || + 'http://libertalia.nemtest.net:7890'; +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 = 120; + + while (attempts < maxAttempts) { + const response = await fetch( + `${NODE_URL}/transaction/get?hash=${hash}`); + if (response.ok) { + console.log(` ${label} confirmed!`); + return response.json(); + } + attempts++; + await new Promise(resolve => { setTimeout(resolve, 2000); }); + } + + throw new Error( + `${label} not confirmed after ${maxAttempts} attempts`); +} + +// Set up sender and recipient accounts [>step-1] +const facade = new NemFacade('testnet'); + +const senderPrivateKeyString = process.env.SENDER_PRIVATE_KEY || + '0000000000000000000000000000000000000000000000000000000000000000'; +const senderKeyPair = new NemFacade.KeyPair( + new PrivateKey(senderPrivateKeyString)); +const senderAddress = facade.network.publicKeyToAddress( + senderKeyPair.publicKey); + +const recipientPrivateKeyString = process.env.RECIPIENT_PRIVATE_KEY || + '1111111111111111111111111111111111111111111111111111111111111111'; +const recipientKeyPair = new NemFacade.KeyPair( + new PrivateKey(recipientPrivateKeyString)); +const recipientAddress = facade.network.publicKeyToAddress( + recipientKeyPair.publicKey); + +console.log('Sender address:', senderAddress.toString()); +console.log('Recipient address:', recipientAddress.toString(), '\n'); +// [ Sending Plain Text Message'); // [>step-2] + +// Create a plain text message +const plainMessage = new TextEncoder().encode('Hello, NEM!'); +console.log('Plain message:', + new TextDecoder().decode(plainMessage)); + +// Build transfer transaction with plain message +const plainTransaction = facade.transactionFactory.create({ + type: 'transfer_transaction_v2', + signerPublicKey: senderKeyPair.publicKey.toString(), + timestamp, + deadline, + recipientAddress: recipientAddress.toString(), + amount: 0n, + message: { + messageType: 'plain', + message: plainMessage + } +}); // [step-3] + +// Wait for confirmation +const plainTxData = await retrieveConfirmedTransaction( + plainTransactionHash, 'Plain message transaction'); + +// Decode plain message from confirmed transaction +const receivedPlainMessage = Buffer.from( + plainTxData.transaction.message.payload, 'hex'); +console.log('Received plain message:', + new TextDecoder().decode(receivedPlainMessage), '\n'); +// [ Sending Encrypted Message'); // [>step-4] + +// 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 encryptedMessage = senderMessageEncoder.encode( + recipientKeyPair.publicKey, secretMessage +); +console.log('Original message:', new TextDecoder().decode(secretMessage)); +console.log('Encrypted payload:', + Buffer.from(encryptedMessage.message).toString('hex')); + +// Build transfer transaction with encrypted message +const encryptedTransaction = facade.transactionFactory.create({ + type: 'transfer_transaction_v2', + signerPublicKey: senderKeyPair.publicKey.toString(), + timestamp, + deadline, + recipientAddress: recipientAddress.toString(), + amount: 0n, + message: { + messageType: 'encrypted', + message: encryptedMessage.message + } +}); // [step-5] + +// 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 = new models.Message(); +receivedEncryptedMessage.messageType = models.MessageType.ENCRYPTED; +receivedEncryptedMessage.message = Buffer.from( + encryptedTxData.transaction.message.payload, 'hex'); + +// Get sender's public key from the transaction +const senderPublicKeyFromTx = new PublicKey( + encryptedTxData.transaction.signer); + +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'); +} // [step-1] +facade = NemFacade('testnet') + +sender_private_key_string = os.getenv( + 'SENDER_PRIVATE_KEY', + '0000000000000000000000000000000000000000000000000000000000000000', +) +sender_key_pair = NemFacade.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 = NemFacade.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') +# [ Sending Plain Text Message') # [>step-2] + +# Create a plain text message +plain_message = 'Hello, NEM!'.encode('utf-8') +print(f'Plain message: {plain_message.decode("utf-8")}') + +# Build transfer transaction with plain message +plain_transaction = facade.transaction_factory.create( + { + 'type': 'transfer_transaction_v2', + 'signer_public_key': sender_key_pair.public_key, + 'timestamp': timestamp, + 'deadline': deadline, + 'recipient_address': recipient_address, + 'amount': 0, + 'message': { + 'message_type': 'plain', + 'message': plain_message, + }, + } +) # [step-3] + +# 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']['payload'] +) +print( + f'Received plain message: {received_plain_message.decode("utf-8")}\n' +) +# [ Sending Encrypted Message') # [>step-4] + +# 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_message = 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_message.message).decode("utf-8")}') + +# Build transfer transaction with encrypted message +encrypted_transaction = facade.transaction_factory.create( + { + 'type': 'transfer_transaction_v2', + 'signer_public_key': sender_key_pair.public_key, + 'timestamp': timestamp, + 'deadline': deadline, + 'recipient_address': recipient_address, + 'amount': 0, + 'message': { + 'message_type': 'encrypted', + 'message': encrypted_message.message, + }, + } +) # [step-5] + +# 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 = Message() +received_encrypted_message.message_type = MessageType.ENCRYPTED +received_encrypted_message.message = bytes.fromhex( + encrypted_tx_data['transaction']['message']['payload'] +) + +# Get sender's public key from the transaction +sender_public_key_from_tx = PublicKey( + encrypted_tx_data['transaction']['signer'] +) + +(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') # [