Messaging Guide
This guide covers sending and receiving encrypted and plaintext messages between addresses using the XChain SDK’s messaging module.
Overview
The MESSAGE action supports three encryption methods:
| Method | Name | Use Case |
|---|---|---|
| 1 | ECIES (default) | Address communication: encrypt to recipient’s public key, no key exchange needed, works across devices |
| 2 | ECDH | Session communication: two-party key exchange for forward secrecy, device-bound |
| 3 | AES | Shared secret communication: pre-shared key encryption, key exchanged out-of-band |
Prerequisites
- Running xchain-explorer, xchain-encoder, and a coin node
- A funded address with UTXOs for sending transactions
Setup
const XChainSDK = require('@dankest-llc/xchain-sdk');
const sdk = new XChainSDK({
network: 'bitcoin-regtest',
explorerUrl: 'http://localhost:8080',
encoderUrl: 'http://localhost:3003',
});
Sending an Encrypted Message (ECIES)
ECIES is the default and recommended encryption method. It encrypts directly to the recipient’s public key. No prior key exchange is needed, and messages can be decrypted on any device that holds the recipient’s private key.
let result = await sdk.sendMessage({
wif: senderWIF,
destination: '1RecipientAddress...',
coin: 'BTC',
message: 'Hello, this is a private message!',
encoder: { pubkey: senderPubkeyHex }
});
console.log('Sent encrypted message, txid:', result.txid);
Under the hood, the SDK:
- Looks up the recipient’s public key via the explorer API
- Generates an ephemeral keypair
- Derives a shared secret via ECDH with the recipient’s public key
- Encrypts the message with AES-256-GCM
- Creates a MESSAGE action, encodes it into a PSBT, signs, and broadcasts
Sending a Plaintext Message
Plaintext messages are visible to everyone on-chain. Pass method: null to skip encryption.
let result = await sdk.sendMessage({
wif: senderWIF,
destination: '1RecipientAddress...',
coin: 'BTC',
message: 'This is a public message visible to everyone!',
method: null,
encoder: { pubkey: senderPubkeyHex }
});
Reading and Decrypting Messages
Fetch messages for an address. Pass your WIF to auto-decrypt ECIES messages addressed to you.
let messages = await sdk.getMessagesForAddress('1MyAddress...', {
wif: myWIF, // Needed to decrypt ECIES messages
type: 'received', // 'sent', 'received', or 'all'
limit: 10
});
for (let msg of messages) {
console.log(`From: ${msg.from}`);
console.log(` Text: ${msg.text}`);
console.log(` Encrypted: ${msg.encrypted}`);
console.log(` Method: ${msg.method}`);
console.log(` Block: ${msg.block}`);
}
Each message object contains:
from: sender addressto: recipient addresscoin: coin network the message was sent on (e.g.'BTC'), ornullif not recordedchain: chain label when fetched viagetAllMessages(e.g.'bitcoin-mainnet'), otherwisenulltext: decrypted message text (ornullif decryption failed or no WIF provided)bytes: raw decryptedBufferfor ECIES messages whenwifis supplied; useful for binary payloads;nullotherwiseencrypted:trueif the message was encryptedmethod: encryption method used (1, 2, 3, ornullfor plaintext)txid: transaction hashblock: block heighttimestamp: block timestamp
Low-Level ECIES Encrypt/Decrypt
Use the messaging module directly for encryption without creating an on-chain transaction.
// Look up a public key
let recipientPubkey = await sdk.getPublicKey('1RecipientAddress...');
// Encrypt
let encrypted = sdk.messaging.eciesEncrypt('Secret message', recipientPubkey);
console.log('Ciphertext:', encrypted.ciphertext);
// Decrypt (recipient side)
let decrypted = sdk.messaging.eciesDecrypt(encrypted.ciphertext, recipientWIF);
console.log('Decrypted:', decrypted.plaintext);
ECDH Session-Based Messaging (Method 2)
ECDH requires a key exchange handshake before sending messages. Both parties exchange public keys via format 0/1 MESSAGE actions, then derive a shared secret for the session.
Note: ECDH sessions are device-bound. The shared secret only exists on the devices that performed the key exchange. Use ECIES (method 1) if you need multi-device support.
// Step 1: Sender generates their session key
let senderSession = sdk.messaging.generateSessionKey(senderWIF);
// Step 2: Send format 0 key exchange request (on-chain)
let keyExchange = await sdk.message({
destination: '1RecipientAddress...',
coin: 'BTC',
encryptionMethod: 2,
encryptionKey: senderSession.publicKey
}, { pubkey: senderPubkeyHex });
// Sign and broadcast keyExchange.psbt...
// Step 3: Recipient responds with format 1 containing their pubkey
let recipientSession = sdk.messaging.generateSessionKey(recipientWIF);
let keyResponse = await sdk.message({
destination: senderAddress,
coin: 'BTC',
encryptionMethod: 2,
encryptionKey: recipientSession.publicKey
}, { pubkey: recipientPubkeyHex });
// Sign and broadcast keyResponse.psbt...
// Step 4: Both sides derive the same shared secret
let senderSecret = sdk.messaging.deriveSharedSecret(senderWIF, recipientSession.publicKey);
let recipientSecret = sdk.messaging.deriveSharedSecret(recipientWIF, senderSession.publicKey);
// senderSecret.sharedSecret === recipientSecret.sharedSecret
// Step 5: Send the ECDH-encrypted message on-chain using the shared secret
let result = await sdk.sendMessage({
wif: senderWIF,
destination: '1RecipientAddress...',
coin: 'BTC',
message: 'Session message',
method: 2,
sharedSecret: senderSecret.sharedSecret,
encoder: { pubkey: senderPubkeyHex }
});
console.log('Sent ECDH session message, txid:', result.txid);
// On the recipient side, decrypt manually (getMessagesForAddress cannot
// auto-decrypt ECDH; supply the shared secret to sessionDecrypt):
let messages = await sdk.getMessagesForAddress('1RecipientAddress...', { type: 'received' });
for (let msg of messages) {
if (msg.encrypted && msg.method === 2) {
// Fetch the raw ciphertext via the explorer, then decrypt:
let decrypted = sdk.messaging.sessionDecrypt(msg.text /* raw hex */, recipientSecret.sharedSecret);
console.log('Decrypted session message:', decrypted.plaintext);
}
}
AES Pre-Shared Key Messaging (Method 3)
AES uses a pre-shared key that both parties know. The key must be exchanged out-of-band (not on-chain).
let sharedKey = 'my-secret-passphrase-shared-between-parties';
// Send
let result = await sdk.sendMessage({
wif: senderWIF,
destination: '1RecipientAddress...',
coin: 'BTC',
message: 'Encrypted with a shared passphrase',
method: 3,
sharedKey: sharedKey,
encoder: { pubkey: senderPubkeyHex }
});
// Decrypt manually: getMessagesForAddress can't auto-decrypt AES, so the app must supply the key
let encrypted = sdk.messaging.aesEncrypt('Test message', sharedKey);
let decrypted = sdk.messaging.aesDecrypt(encrypted.ciphertext, sharedKey);
Public Key Lookup
Look up the public key for any address that has sent at least one XChain transaction.
let pubkey = await sdk.getPublicKey('1SomeAddress...');
if (pubkey) {
console.log('Public key:', pubkey);
} else {
console.log('No public key found (address may not have sent any XChain transactions)');
}
Choosing an Encryption Method
| Consideration | ECIES (1) | ECDH (2) | AES (3) |
|---|---|---|---|
| Key exchange needed? | No | Yes (format 0/1) | Out-of-band |
| Multi-device support | Yes | No (device-bound) | Yes (if key is shared) |
| Forward secrecy | Per-message (ephemeral keys) | Per-session | No |
| Best for | General messaging | Long-running sessions | Password-protected messages |
Next Steps
- Advanced_Token_Features.md: gated file delivery using a self-MESSAGE key handoff
- Query_The_Explorer.md: read messages and other action data via the REST and JSON-RPC APIs
- Integration_Patterns.md: using messaging in production wallets and applications
Copyright © 2025–2026 Dankest, LLC
Based on XChain Platform by Dankest, LLC – https://dankest.llc
Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0-or-later) with a commercial license available for proprietary use.
You may use, modify, and distribute this material under the terms of the License. See LICENSE and NOTICE for full terms. See the licensing overview.