Menu
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?

BenefitDescription
No node requiredUse existing wallet infrastructure
Permission-basedGrant specific capabilities
Nostr-nativeUses familiar Nostr patterns
RevocableDisconnect 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
PartDescription
pubkeyWallet service’s public key
relayRelay for communication
secretShared 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

KindPurposeDirection
23194RequestClient → Wallet
23195ResponseWallet → 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

CodeDescription
RATE_LIMITEDToo many requests
NOT_IMPLEMENTEDMethod not supported
INSUFFICIENT_BALANCENot enough funds
PAYMENT_FAILEDPayment couldn’t complete
NOT_FOUNDInvoice/transaction not found
QUOTA_EXCEEDEDSpending limit reached
RESTRICTEDPermission denied
UNAUTHORIZEDBad authentication
INTERNALWallet service error

Wallet Providers

ProviderConnection
Albynwc.getalby.com
MutinyNative NWC
CoinosNWC support
LNbitsNWC 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"
  ]
}