NIP-nip-47 Final
NIP-47: Nostr Wallet Connect
Control Lightning wallets through Nostr. Enable agents to make payments via wallet services.
| Type | Nostr Implementation Possibility |
| Number | nip-47 |
| Status | Final |
| Original | https://github.com/nostr-protocol/nips/blob/master/47.md |
NIP-47: Nostr Wallet Connect
Status: Final
NIP-47 (Nostr Wallet Connect) allows apps and agents to control Lightning wallets through Nostr events. Instead of managing your own Lightning node, connect to a wallet service via Nostr.
Why NWC for Agents?
| Benefit | Description |
|---|---|
| No node required | Use existing wallet infrastructure |
| Permission-based | Grant specific capabilities |
| Nostr-native | Uses familiar Nostr patterns |
| Revocable | Disconnect anytime |
Architecture
┌───────────┐ ┌─────────────┐ ┌──────────────┐
│ Agent │ │ Relay │ │ Wallet App │
│ (Client) │ │ │ │ (Service) │
└─────┬─────┘ └──────┬──────┘ └──────┬───────┘
│ │ │
│─── Request ─────►│──────────────────►│
│ (kind 23194) │ │
│ │ │
│ │◄─── Response ─────│
│◄─────────────────│ (kind 23195) │
│ │ │
Connection String
NWC uses a nostr+walletconnect:// URI:
nostr+walletconnect://pubkey?relay=wss://relay&secret=hex
| Part | Description |
|---|---|
pubkey | Wallet service’s public key |
relay | Relay for communication |
secret | Shared secret for encryption |
Parsing Connection String
from urllib.parse import urlparse, parse_qs
def parse_nwc_uri(uri: str) -> dict:
"""Parse NWC connection string."""
if not uri.startswith("nostr+walletconnect://"):
raise ValueError("Invalid NWC URI")
# Remove scheme
rest = uri.replace("nostr+walletconnect://", "")
# Split pubkey and params
if "?" in rest:
pubkey, params_str = rest.split("?", 1)
else:
pubkey = rest
params_str = ""
params = parse_qs(params_str)
return {
"pubkey": pubkey,
"relay": params.get("relay", [None])[0],
"secret": params.get("secret", [None])[0]
}
Event Kinds
| Kind | Purpose | Direction |
|---|---|---|
| 23194 | Request | Client → Wallet |
| 23195 | Response | Wallet → Client |
Request Format (Kind 23194)
{
"kind": 23194,
"pubkey": "client-pubkey",
"tags": [["p", "wallet-pubkey"]],
"content": "<encrypted-json>",
"created_at": 1234567890
}
The content is NIP-04 encrypted JSON:
{
"method": "pay_invoice",
"params": {
"invoice": "lnbc..."
}
}
Response Format (Kind 23195)
{
"kind": 23195,
"pubkey": "wallet-pubkey",
"tags": [
["p", "client-pubkey"],
["e", "request-event-id"]
],
"content": "<encrypted-json>",
"created_at": 1234567890
}
Response content:
{
"result_type": "pay_invoice",
"result": {
"preimage": "abc123..."
}
}
Or error:
{
"result_type": "pay_invoice",
"error": {
"code": "INSUFFICIENT_BALANCE",
"message": "Not enough funds"
}
}
Supported Methods
pay_invoice
Pay a BOLT11 invoice:
{
"method": "pay_invoice",
"params": {
"invoice": "lnbc10u1p..."
}
}
Response:
{
"result_type": "pay_invoice",
"result": {
"preimage": "payment-preimage-hex"
}
}
make_invoice
Create an invoice:
{
"method": "make_invoice",
"params": {
"amount": 1000,
"description": "Payment for service"
}
}
Response:
{
"result_type": "make_invoice",
"result": {
"invoice": "lnbc10u1p...",
"payment_hash": "hash-hex"
}
}
get_balance
Check wallet balance:
{
"method": "get_balance"
}
Response:
{
"result_type": "get_balance",
"result": {
"balance": 50000
}
}
lookup_invoice
Check invoice status:
{
"method": "lookup_invoice",
"params": {
"payment_hash": "hash-hex"
}
}
list_transactions
Get transaction history:
{
"method": "list_transactions",
"params": {
"limit": 10
}
}
Implementation
NWC Client Class
import asyncio
import json
import time
import websockets
from nip04 import encrypt, decrypt # Use NIP-04 encryption
class NWCClient:
def __init__(self, connection_string: str, client_privkey: str):
self.config = parse_nwc_uri(connection_string)
self.client_privkey = client_privkey
self.client_pubkey = derive_pubkey(client_privkey)
self.ws = None
async def connect(self):
self.ws = await websockets.connect(self.config["relay"])
# Subscribe to responses
sub_filter = {
"kinds": [23195],
"#p": [self.client_pubkey]
}
await self.ws.send(json.dumps(["REQ", "nwc", sub_filter]))
async def request(self, method: str, params: dict = None) -> dict:
"""Send NWC request and wait for response."""
# Create request payload
payload = {"method": method}
if params:
payload["params"] = params
# Encrypt with wallet's pubkey
encrypted = encrypt(
json.dumps(payload),
self.client_privkey,
self.config["pubkey"]
)
# Create request event
event = create_and_sign_event(
kind=23194,
content=encrypted,
tags=[["p", self.config["pubkey"]]],
privkey=self.client_privkey
)
# Publish request
await self.ws.send(json.dumps(["EVENT", event]))
# Wait for response
response = await self._wait_for_response(event["id"])
return response
async def _wait_for_response(self, request_id: str, timeout: int = 30):
"""Wait for response to specific request."""
deadline = time.time() + timeout
while time.time() < deadline:
try:
msg = await asyncio.wait_for(
self.ws.recv(),
timeout=deadline - time.time()
)
data = json.loads(msg)
if data[0] == "EVENT" and data[1] == "nwc":
event = data[2]
# Check if this is our response
e_tags = [t for t in event["tags"] if t[0] == "e"]
if any(t[1] == request_id for t in e_tags):
# Decrypt and return
decrypted = decrypt(
event["content"],
self.client_privkey,
self.config["pubkey"]
)
return json.loads(decrypted)
except asyncio.TimeoutError:
break
raise TimeoutError("No response received")
async def pay_invoice(self, invoice: str) -> dict:
"""Pay a BOLT11 invoice."""
return await self.request("pay_invoice", {"invoice": invoice})
async def make_invoice(self, amount_msats: int, description: str = "") -> dict:
"""Create an invoice."""
return await self.request("make_invoice", {
"amount": amount_msats,
"description": description
})
async def get_balance(self) -> int:
"""Get wallet balance in msats."""
result = await self.request("get_balance")
return result.get("result", {}).get("balance", 0)
async def close(self):
if self.ws:
await self.ws.close()
Usage Example
async def main():
# Get connection string from wallet (e.g., Alby)
nwc_uri = "nostr+walletconnect://pubkey?relay=wss://relay&secret=..."
# Create client
client = NWCClient(nwc_uri, MY_PRIVATE_KEY)
await client.connect()
# Check balance
balance = await client.get_balance()
print(f"Balance: {balance} msats")
# Pay an invoice
result = await client.pay_invoice("lnbc10u1p...")
if "error" in result:
print(f"Payment failed: {result['error']['message']}")
else:
print(f"Paid! Preimage: {result['result']['preimage']}")
# Create invoice
invoice_result = await client.make_invoice(
amount_msats=10000,
description="Test invoice"
)
print(f"Invoice: {invoice_result['result']['invoice']}")
await client.close()
Error Codes
| Code | Description |
|---|---|
RATE_LIMITED | Too many requests |
NOT_IMPLEMENTED | Method not supported |
INSUFFICIENT_BALANCE | Not enough funds |
PAYMENT_FAILED | Payment couldn’t complete |
NOT_FOUND | Invoice/transaction not found |
QUOTA_EXCEEDED | Spending limit reached |
RESTRICTED | Permission denied |
UNAUTHORIZED | Bad authentication |
INTERNAL | Wallet service error |
Wallet Providers
| Provider | Connection |
|---|---|
| Alby | nwc.getalby.com |
| Mutiny | Native NWC |
| Coinos | NWC support |
| LNbits | NWC extension |
Security Considerations
Permissions
NWC connections can have limited permissions:
- Pay only
- Receive only
- Balance check only
- Spending limits
Revocation
Connections can be revoked anytime from the wallet app.
Secret Key
The secret in the connection string is sensitive:
- Store securely
- Don’t log
- Unique per connection
Machine-Readable Summary
{
"nip": 47,
"title": "Nostr Wallet Connect",
"status": "final",
"defines": [
"connection-string-format",
"request-response-protocol",
"wallet-methods"
],
"event_kinds": {
"request": 23194,
"response": 23195
},
"methods": [
"pay_invoice",
"make_invoice",
"get_balance",
"lookup_invoice",
"list_transactions"
],
"encryption": "nip-04",
"providers": [
"alby",
"mutiny",
"lnbits"
],
"related": [
"/learn/nostr/zaps",
"/learn/lightning/invoices",
"/learn/nostr/specs/nip-57"
]
}