A Unity implementation for connecting to Bitcoin Lightning wallets via the Nostr Wallet Connect protocol.
- What is Nostr Wallet Connect?
- High-Level Architecture
- How It Works
- Key Components
- Encryption & Security
- Message Flow
- Technical Deep Dive
- Usage Examples
- Troubleshooting
Nostr Wallet Connect (NWC) is a protocol that allows applications to connect to Lightning wallets remotely and securely. Instead of managing Bitcoin/Lightning keys directly in your app, you connect to an external wallet that handles all the cryptographic operations.
- Security: Your app never touches Lightning keys
- Simplicity: No need to implement Lightning Network complexity
- Interoperability: Works with any NWC-compatible wallet
- Real-time: Uses Nostr for instant communication
Unity App ←→ Nostr Relay ←→ Lightning Wallet
↑ ↑ ↑
Encrypted Message Handles Bitcoin
Requests Routing Operations
- Unity App creates Lightning requests (invoices, payments)
- Encrypts them using shared secrets
- Sends via Nostr relay to the wallet
- Wallet processes Lightning operations
- Responds back through the same encrypted channel
User provides connection string:
nostr+walletconnect://WALLET_PUBKEY?relay=wss://relay.url&secret=SHARED_SECRET
This contains:
- Wallet's public key: Who to send messages to
- Relay URL: Which Nostr relay to use for communication
- Shared secret: For encrypting messages between app and wallet
- Outgoing (App → Wallet): Uses NIP-04 encryption (AES-256-CBC + IV)
- Incoming (Wallet → App): Uses NIP-44 encryption (ChaCha20 + HMAC)
- Hybrid approach: Your wallet expects different formats for requests vs responses
Once connected, you can:
- Create invoices:
MakeInvoiceAsync(amount, description) - Pay invoices:
PayInvoiceAsync(bolt11_invoice) - Check balance:
GetBalanceAsync() - Get wallet info:
GetInfoAsync()
The main controller that orchestrates everything:
- Manages WebSocket connections to Nostr relays
- Handles encryption/decryption of messages
- Provides high-level Lightning operation methods
- Manages threading (background networking, main thread UI updates)
WebSocket client for real-time Nostr communication:
- Handles multi-part message assembly (fixes truncation issues)
- Manages connection lifecycle
- Processes incoming Nostr events
Cryptographic operations:
- NIP-04 encryption: For outgoing requests to wallet
- NIP-44 decryption: For incoming responses from wallet
- Key derivation: ECDH shared secrets, HKDF key expansion
- Hybrid compatibility: Supports both encryption standards
Specialized NIP-44 implementation:
- ChaCha20 stream cipher: Proper implementation (not AES simulation)
- HMAC authentication: Message integrity verification
- Padding/unpadding: Message format compliance
Your wallet uses a hybrid approach:
- Requests (Unity → Wallet): Expects NIP-04 format
- Responses (Wallet → Unity): Sends NIP-44 format
This is wallet-specific behavior that we discovered during development.
// Shared secret from ECDH (raw X-coordinate, no hashing)
var sharedSecret = ComputeSharedSecret(walletPubkey, clientPrivateKey);
// AES-256-CBC encryption with random IV
var encrypted = AESEncrypt(message, sharedSecret, randomIV);
// Format: "base64_encrypted_data?iv=base64_iv"
var content = Convert.ToBase64String(encrypted) + "?iv=" + Convert.ToBase64String(iv);// Same shared secret, different key derivation
var conversationKey = HKDF(sharedSecret, salt="nip44-v2");
// ChaCha20 decryption with HMAC verification
var plaintext = ChaCha20Decrypt(ciphertext, derivedKey, nonce);
var isValid = HMAC_Verify(ciphertext, derivedAuthKey, receivedMAC);-
User calls
MakeInvoiceAsync(1000, "Test invoice") -
Request creation:
{ "method": "make_invoice", "params": { "amount": 1000, "description": "Test invoice" } } -
NIP-04 encryption:
plaintext → AES-256-CBC → base64 → "encrypted?iv=random" -
Nostr event creation:
{ "kind": 23194, "pubkey": "client_pubkey", "content": "encrypted_content", "tags": [["p", "wallet_pubkey"]] } -
WebSocket send to Nostr relay
-
Relay forwards to wallet
-
Wallet processes Lightning invoice creation
-
Wallet responds with NIP-44 encrypted result
-
Unity decrypts and displays result
- Background Thread: WebSocket communication, message receiving
- Main Thread: UI updates, user interactions
- Queue System: Background thread queues responses for main thread processing
// Background thread (WebSocket)
lock (_pendingNWCResponses) {
_pendingNWCResponses.Enqueue(response);
}
// Main thread (Update loop)
while (_pendingNWCResponses.Count > 0) {
var response = _pendingNWCResponses.Dequeue();
HandleNWCResponse(response); // Safe for Unity UI calls
}Challenge: WebSocket messages can be split into multiple frames for large messages.
Solution: Proper multi-frame assembly:
do {
result = await _webSocket.ReceiveAsync(buffer, cancellationToken);
if (result.MessageType == WebSocketMessageType.Text) {
messageBuilder.Append(Encoding.UTF8.GetString(buffer, 0, result.Count));
}
} while (!result.EndOfMessage); // Keep reading until complete message
var completeMessage = messageBuilder.ToString();Critical detail: The JavaScript reference implementation uses:
let sharedPoint = secp.getSharedSecret(privateKey, '02' + publicKey)
let sharedSecret = sharedPoint.slice(1, 33) // Skip first byte, take next 32Our C# equivalent:
var sharedPoint = pubKey.GetSharedPubkey(privKey);
var xCoord = sharedPoint.ToXOnlyPubKey().ToBytes(); // 32-byte X coordinate
// No hashing - use raw X coordinate as shared secretNot just any stream cipher - proper ChaCha20 with:
- Quarter-round function with specific bit rotations
- 20 rounds of the quarter-round function
- Proper state matrix initialization
- Block counter increment
private static void QuarterRound(uint[] state, int a, int b, int c, int d) {
state[a] += state[b]; state[d] ^= state[a]; state[d] = RotateLeft(state[d], 16);
state[c] += state[d]; state[b] ^= state[c]; state[b] = RotateLeft(state[b], 12);
// ... continue with proper ChaCha20 quarter round
}Comprehensive logging throughout the pipeline:
- Hex dumps of cryptographic operations
- WebSocket message assembly details
- Event processing flow
- Error context and recovery attempts
Example debugging output:
[14:43:46] === NIP-44 DECRYPTION START ===
[14:43:46] Payload length: 195 bytes
[14:43:46] Salt (32 bytes): 148FA262BCFA5C94...
[14:43:46] Derived enc key (32 bytes): 8CF203C3870FD9E9...
[14:43:46] ✅ HMAC verification passed!
[14:43:46] ✅ NIP-44 decryption successful
public class MyWalletApp : MonoBehaviour {
[SerializeField] private NostrWalletConnect nwc;
async void Start() {
// Connection string from your wallet
string connectionString = "nostr+walletconnect://...";
// Set up event handlers
nwc.OnConnected += () => Debug.Log("Wallet connected!");
nwc.OnResponse += HandleWalletResponse;
// Connect
bool success = await nwc.ConnectAsync(connectionString);
if (success) {
Debug.Log("Connected to wallet!");
}
}
void HandleWalletResponse(NWCResponse response) {
if (response.Error != null) {
Debug.LogError($"Wallet error: {response.Error.Message}");
} else {
Debug.Log($"Success: {JsonConvert.SerializeObject(response.Result)}");
}
}
}public async Task CreateInvoice() {
try {
await nwc.MakeInvoiceAsync(1000, "Payment for premium features");
// Response will come through OnResponse event
} catch (Exception ex) {
Debug.LogError($"Failed to create invoice: {ex.Message}");
}
}public async Task PayInvoice(string bolt11Invoice) {
try {
await nwc.PayInvoiceAsync(bolt11Invoice);
// Response will confirm payment status
} catch (Exception ex) {
Debug.LogError($"Failed to pay invoice: {ex.Message}");
}
}Cause: Old cached responses from failed connection attempts Solution: These are normal and will stop once the Nostr relay finishes sending cached events
Cause: WebSocket messages being truncated Solution: Fixed with proper multi-frame message assembly
Cause: WebSocket callbacks trying to update UI from background thread Solution: Queue system processes responses on main thread in Update()
Cause: Incorrect encryption preventing wallet from reading requests Solution: Ensure proper NIP-04/NIP-44 hybrid implementation
Enable detailed logging in DebugLogger.cs to see:
- Complete message encryption/decryption flow
- WebSocket message assembly
- Cryptographic operation details
- Event processing timeline
Use the included test functions:
[ContextMenu("Test Crypto Functions")]
private void TestCryptoFunctions() {
// Tests NIP-04 encryption/decryption roundtrip
}
[ContextMenu("Test Connection String Parser")]
private void TestConnectionStringParser() {
// Validates connection string parsing
}Different wallets implement NWC slightly differently. Some expect NIP-04, others NIP-44. Your specific wallet expects NIP-04 requests but sends NIP-44 responses. This hybrid approach ensures compatibility.
Unity doesn't have built-in ChaCha20. Many implementations online are incorrect or use AES as a substitute. We implemented proper ChaCha20 from the specification for authentic NIP-44 support.
Unity's main thread handles UI and MonoBehaviour operations. WebSocket operations must run on background threads. The queue system safely bridges these two worlds.
Cryptographic protocols are complex and debugging encryption issues requires visibility into every step. The comprehensive logging helps diagnose exactly where issues occur.
This implementation provides a complete, production-ready NWC client for Unity that handles the complexities of Nostr communication, hybrid encryption standards, and Unity's threading requirements.