feat: add server-side event builders and NIP-47 README for quartz

Adds missing server-side capabilities to the NIP-47 quartz module:

- LnZapPaymentResponseEvent.createResponse(): builds encrypted response
  events (kind 23195) for wallet services to reply to client requests
- NwcNotificationEvent.createNotification(): builds encrypted notification
  events (kind 23197) for wallet services to push payment notifications

Creates comprehensive README.md documenting the full NIP-47 API with
code examples for both wallet client and wallet service implementations,
covering URI parsing, request/response building, encryption, notifications,
error handling, transaction states, and caching.

https://claude.ai/code/session_01JFogiwyR4CPVP8cRznqahJ
This commit is contained in:
Claude
2026-03-14 04:25:27 +00:00
parent 4b76c70c29
commit 580678eed8
3 changed files with 550 additions and 1 deletions
@@ -26,6 +26,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class LnZapPaymentResponseEvent(
@@ -55,6 +57,43 @@ class LnZapPaymentResponseEvent(
companion object {
const val KIND = 23195
const val ALT = "Zap payment response"
const val ALT = "NWC response"
/**
* Creates an NWC response event (server-side).
*
* @param response the NWC response object to send
* @param requestEvent the original request event being responded to
* @param signer the wallet service signer
* @param useNip44 whether to use NIP-44 encryption (default: false for NIP-04)
* @param createdAt event timestamp
*/
suspend fun createResponse(
response: Response,
requestEvent: LnZapPaymentRequestEvent,
signer: NostrSigner,
useNip44: Boolean = false,
createdAt: Long = TimeUtils.now(),
): LnZapPaymentResponseEvent {
val serializedResponse = OptimizedJsonMapper.toJson(response)
val clientPubkey = requestEvent.pubKey
val tags =
arrayOf(
arrayOf("p", clientPubkey),
arrayOf("e", requestEvent.id),
AltTag.assemble(ALT),
)
val encrypted =
if (useNip44) {
signer.nip44Encrypt(serializedResponse, clientPubkey)
} else {
signer.nip04Encrypt(serializedResponse, clientPubkey)
}
return signer.sign(createdAt, KIND, tags, encrypted)
}
}
}
@@ -26,6 +26,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class NwcNotificationEvent(
@@ -54,5 +56,33 @@ class NwcNotificationEvent(
const val KIND = 23197
const val LEGACY_KIND = 23196
const val ALT = "Wallet notification"
/**
* Creates an NWC notification event (server-side).
* Uses NIP-44 encryption (kind 23197).
*
* @param notification the notification to send
* @param clientPubkey the client's public key to encrypt to
* @param signer the wallet service signer
* @param createdAt event timestamp
*/
suspend fun createNotification(
notification: Notification,
clientPubkey: HexKey,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): NwcNotificationEvent {
val serialized = OptimizedJsonMapper.toJson(notification)
val tags =
arrayOf(
arrayOf("p", clientPubkey),
AltTag.assemble(ALT),
)
val encrypted = signer.nip44Encrypt(serialized, clientPubkey)
return signer.sign(createdAt, KIND, tags, encrypted)
}
}
}
@@ -0,0 +1,480 @@
# NIP-47 Wallet Connect (Quartz)
Quartz implementation of [NIP-47](https://github.com/nostr-protocol/nips/blob/master/47.md) — Nostr
Wallet Connect (NWC). This module provides everything needed to build both **wallet client apps**
(like Amethyst) and **wallet service backends** (like Alby Hub).
## Architecture
```
nip47WalletConnect/
├── Nip47WalletConnect.kt # URI parsing (nostr+walletconnect://)
├── Request.kt # All 13 NWC request methods + params
├── Response.kt # All response types (success + error)
├── Notification.kt # Wallet notification types
├── NwcMethod.kt # Method name constants
├── NwcErrorCode.kt # Error codes enum + NwcError
├── NwcTransaction.kt # Transaction, state, budget, TLV models
├── NwcInfoEvent.kt # Kind 13194 — wallet capabilities
├── LnZapPaymentRequestEvent.kt # Kind 23194 — client → wallet request
├── LnZapPaymentResponseEvent.kt # Kind 23195 — wallet → client response
├── NwcNotificationEvent.kt # Kind 23197 — wallet → client notification
├── NostrWalletConnectRequestCache.kt # Request decryption cache
├── NostrWalletConnectResponseCache.kt # Response decryption cache
└── tags/
├── EncryptionTag.kt # "encryption" tag parsing
└── NotificationsTag.kt # "notifications" tag parsing
```
## Event Kinds
| Kind | Class | Direction | Purpose |
|-------|------------------------------|-----------------|----------------------|
| 13194 | `NwcInfoEvent` | Wallet → Relay | Service capabilities |
| 23194 | `LnZapPaymentRequestEvent` | Client → Wallet | NWC request |
| 23195 | `LnZapPaymentResponseEvent` | Wallet → Client | NWC response |
| 23196 | `NwcNotificationEvent` | Wallet → Client | Notification (NIP-04, legacy) |
| 23197 | `NwcNotificationEvent` | Wallet → Client | Notification (NIP-44) |
## Supported Methods
| Method | Request Class | Success Response Class |
|----------------------|---------------------------|-----------------------------------|
| `pay_invoice` | `PayInvoiceMethod` | `PayInvoiceSuccessResponse` |
| `pay_keysend` | `PayKeysendMethod` | `PayKeysendSuccessResponse` |
| `make_invoice` | `MakeInvoiceMethod` | `MakeInvoiceSuccessResponse` |
| `lookup_invoice` | `LookupInvoiceMethod` | `LookupInvoiceSuccessResponse` |
| `list_transactions` | `ListTransactionsMethod` | `ListTransactionsSuccessResponse` |
| `get_balance` | `GetBalanceMethod` | `GetBalanceSuccessResponse` |
| `get_info` | `GetInfoMethod` | `GetInfoSuccessResponse` |
| `get_budget` | `GetBudgetMethod` | `GetBudgetSuccessResponse` |
| `sign_message` | `SignMessageMethod` | `SignMessageSuccessResponse` |
| `create_connection` | `CreateConnectionMethod` | `CreateConnectionSuccessResponse` |
| `make_hold_invoice` | `MakeHoldInvoiceMethod` | `MakeHoldInvoiceSuccessResponse` |
| `cancel_hold_invoice`| `CancelHoldInvoiceMethod` | `CancelHoldInvoiceSuccessResponse`|
| `settle_hold_invoice`| `SettleHoldInvoiceMethod` | `SettleHoldInvoiceSuccessResponse`|
Any method can also return `NwcErrorResponse` or (for `pay_invoice`) `PayInvoiceErrorResponse`.
## Implementing a Wallet Client
A wallet client connects to a user's lightning wallet to send payments, check
balances, create invoices, and list transactions.
### 1. Parse the NWC Connection URI
Users provide an NWC connection string from their wallet provider:
```kotlin
val uri = "nostr+walletconnect://b889ff5b...?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c..."
val nwcConfig = Nip47WalletConnect.parse(uri)
// nwcConfig.pubKeyHex — wallet service pubkey
// nwcConfig.relayUri — relay to communicate through (NormalizedRelayUrl)
// nwcConfig.secret — hex secret for the client signer
// nwcConfig.lud16 — optional lightning address
```
Supported URI schemes: `nostr+walletconnect://`, `nostrwalletconnect://`,
`amethyst+walletconnect://`
### 2. Create the Client Signer
The `secret` from the URI becomes the client's signing key:
```kotlin
val clientSigner = NostrSignerInternal(
KeyPair(nwcConfig.secret!!.hexToByteArray())
)
```
### 3. Build and Send Requests
Use `LnZapPaymentRequestEvent.createRequest()` to create encrypted request events:
```kotlin
// Get balance
val balanceRequest = GetBalanceMethod.create()
val event = LnZapPaymentRequestEvent.createRequest(
request = balanceRequest,
walletServicePubkey = nwcConfig.pubKeyHex,
signer = clientSigner,
)
// Send `event` to `nwcConfig.relayUri`
// Pay an invoice
val payRequest = PayInvoiceMethod.create("lnbc50n1...")
val payEvent = LnZapPaymentRequestEvent.createRequest(
request = payRequest,
walletServicePubkey = nwcConfig.pubKeyHex,
signer = clientSigner,
)
// Create an invoice (amount in millisats)
val invoiceRequest = MakeInvoiceMethod.create(
amount = 50000L, // 50 sats in millisats
description = "Coffee",
)
val invoiceEvent = LnZapPaymentRequestEvent.createRequest(
request = invoiceRequest,
walletServicePubkey = nwcConfig.pubKeyHex,
signer = clientSigner,
)
// List transactions with pagination
val listRequest = ListTransactionsMethod.create(
limit = 20,
offset = 0,
)
val listEvent = LnZapPaymentRequestEvent.createRequest(
request = listRequest,
walletServicePubkey = nwcConfig.pubKeyHex,
signer = clientSigner,
)
```
To use NIP-44 encryption instead of NIP-04:
```kotlin
val event = LnZapPaymentRequestEvent.createRequest(
request = GetInfoMethod.create(),
walletServicePubkey = nwcConfig.pubKeyHex,
signer = clientSigner,
useNip44 = true,
)
```
### 4. Receive and Parse Responses
Subscribe to kind `23195` events on the NWC relay, filtered by the wallet
service pubkey and the request event ID. When a response arrives:
```kotlin
// responseEvent is a LnZapPaymentResponseEvent (kind 23195)
val response: Response = responseEvent.decrypt(clientSigner)
when (response) {
is GetBalanceSuccessResponse -> {
val balanceMillisats = response.result?.balance ?: 0L
val balanceSats = balanceMillisats / 1000L
}
is PayInvoiceSuccessResponse -> {
val preimage = response.result?.preimage
val feesPaid = response.result?.fees_paid
}
is MakeInvoiceSuccessResponse -> {
val bolt11 = response.result?.invoice
val paymentHash = response.result?.payment_hash
}
is ListTransactionsSuccessResponse -> {
val transactions: List<NwcTransaction> = response.result?.transactions ?: emptyList()
transactions.forEach { tx ->
// tx.type — "incoming" or "outgoing"
// tx.amount — in millisats
// tx.description, tx.created_at, tx.state, etc.
}
}
is GetInfoSuccessResponse -> {
val alias = response.result?.alias
val methods = response.result?.methods // supported methods
val lud16 = response.result?.lud16
}
is PayInvoiceErrorResponse -> {
val errorCode = response.error?.code // NwcErrorCode enum
val errorMessage = response.error?.message
}
is NwcErrorResponse -> {
val errorCode = response.error?.code
val errorMessage = response.error?.message
}
}
```
### 5. Listen for Notifications (Optional)
Subscribe to kind `23196`/`23197` events from the wallet:
```kotlin
// notificationEvent is an NwcNotificationEvent
val notification: Notification = notificationEvent.decryptNotification(clientSigner)
when (notification) {
is PaymentReceivedNotification -> {
val tx: NwcTransaction? = notification.notification
// tx?.amount, tx?.description, tx?.payment_hash, etc.
}
is PaymentSentNotification -> {
val tx: NwcTransaction? = notification.notification
}
is HoldInvoiceAcceptedNotification -> {
val data = notification.notification
// data?.payment_hash, data?.amount, data?.settle_deadline
}
}
```
### 6. Transaction State Helpers
Transaction states from different wallet implementations may use different
casing. Use the case-insensitive helpers:
```kotlin
val tx: NwcTransaction = ...
NwcTransactionState.isSettled(tx.state) // true for "SETTLED" or "settled"
NwcTransactionState.isPending(tx.state) // true for "PENDING" or "pending"
NwcTransactionState.isFailed(tx.state) // true for "FAILED" or "failed"
NwcTransactionState.isAccepted(tx.state) // true for "ACCEPTED" or "accepted"
```
### 7. URI Persistence
To save/restore the NWC connection:
```kotlin
// Save
val nip47URI: Nip47WalletConnect.Nip47URI = nwcConfig.denormalize()!!
val json = Nip47WalletConnect.Nip47URI.serializer(nip47URI)
// Restore
val restored = Nip47WalletConnect.Nip47URI.parser(json)
val normalized = restored.normalize()!!
```
## Implementing a Wallet Service
A wallet service receives NWC requests from clients, processes them (e.g.,
pays invoices via a Lightning node), and sends back responses.
### 1. Publish Capabilities
Advertise which methods your wallet supports by publishing a kind `13194`
event:
```kotlin
val capabilities = listOf(
NwcMethod.PAY_INVOICE,
NwcMethod.GET_BALANCE,
NwcMethod.GET_INFO,
NwcMethod.MAKE_INVOICE,
NwcMethod.LOOKUP_INVOICE,
NwcMethod.LIST_TRANSACTIONS,
)
val infoTemplate = NwcInfoEvent.build(
capabilities = capabilities,
encryptionSchemes = listOf("nip04", "nip44_v2"),
notificationTypes = listOf(
NwcNotificationType.PAYMENT_RECEIVED,
NwcNotificationType.PAYMENT_SENT,
),
)
// Sign with wallet signer: walletSigner.sign(infoTemplate)
```
### 2. Receive and Parse Requests
Subscribe to kind `23194` events on your relay, filtered by your wallet
service pubkey in the `p` tag. When a request arrives:
```kotlin
// requestEvent is a LnZapPaymentRequestEvent (kind 23194)
val request: Request = requestEvent.decryptRequest(walletSigner)
when (request) {
is PayInvoiceMethod -> {
val bolt11 = request.params?.invoice
val amount = request.params?.amount // optional override in millisats
// Process payment via your Lightning node...
}
is GetBalanceMethod -> {
// Query your Lightning node for balance...
}
is MakeInvoiceMethod -> {
val amount = request.params?.amount // in millisats
val description = request.params?.description
// Create invoice via your Lightning node...
}
is ListTransactionsMethod -> {
val limit = request.params?.limit
val offset = request.params?.offset
val type = request.params?.type // "incoming" or "outgoing"
// Query transaction history...
}
is GetInfoMethod -> {
// Return node info...
}
is GetBudgetMethod -> {
// Return budget info...
}
// ... handle other methods
}
```
### 3. Build and Send Responses
Use `LnZapPaymentResponseEvent.createResponse()` to create encrypted
response events:
```kotlin
// Success response for get_balance
val balanceResponse = GetBalanceSuccessResponse(
GetBalanceSuccessResponse.GetBalanceResult(balance = 2100000L) // in millisats
)
val responseEvent = LnZapPaymentResponseEvent.createResponse(
response = balanceResponse,
requestEvent = requestEvent,
signer = walletSigner,
)
// Send responseEvent to the relay
// Success response for pay_invoice
val payResponse = PayInvoiceSuccessResponse(
PayInvoiceSuccessResponse.PayInvoiceResultParams(
preimage = "0123456789abcdef",
fees_paid = 100L,
)
)
val payResponseEvent = LnZapPaymentResponseEvent.createResponse(
response = payResponse,
requestEvent = requestEvent,
signer = walletSigner,
)
// Error response
val errorResponse = NwcErrorResponse(
resultType = NwcMethod.PAY_INVOICE,
error = NwcError(NwcErrorCode.INSUFFICIENT_BALANCE, "Not enough funds"),
)
val errorEvent = LnZapPaymentResponseEvent.createResponse(
response = errorResponse,
requestEvent = requestEvent,
signer = walletSigner,
)
```
To use NIP-44 encryption for responses:
```kotlin
val responseEvent = LnZapPaymentResponseEvent.createResponse(
response = balanceResponse,
requestEvent = requestEvent,
signer = walletSigner,
useNip44 = true,
)
```
### 4. Send Notifications
Push notifications to clients for payment events:
```kotlin
// Payment received notification
val notification = PaymentReceivedNotification(
notification = NwcTransaction(
type = NwcTransactionType.INCOMING,
state = NwcTransactionState.SETTLED,
invoice = "lnbc...",
amount = 50000L, // in millisats
payment_hash = "abc123",
settled_at = TimeUtils.now(),
created_at = TimeUtils.now(),
),
)
val notifEvent = NwcNotificationEvent.createNotification(
notification = notification,
clientPubkey = clientPubkeyHex,
signer = walletSigner,
)
// Send notifEvent to the relay
```
### 5. Build Transaction Objects
Transactions are used across multiple response types:
```kotlin
val transaction = NwcTransaction(
type = NwcTransactionType.INCOMING, // or OUTGOING
state = NwcTransactionState.SETTLED, // PENDING, SETTLED, FAILED, ACCEPTED
invoice = "lnbc50n1...",
description = "Coffee payment",
payment_hash = "abc123def456",
preimage = "fedcba654321",
amount = 50000L, // in millisats
fees_paid = 100L, // in millisats
created_at = 1693876497L, // unix timestamp
settled_at = 1693876500L,
expires_at = 1694876497L,
)
```
## Error Codes
| Code | When to Use |
|-------------------------|-------------------------------------------|
| `RATE_LIMITED` | Too many requests |
| `NOT_IMPLEMENTED` | Method not supported by wallet |
| `INSUFFICIENT_BALANCE` | Not enough funds for payment |
| `PAYMENT_FAILED` | Payment could not be completed |
| `QUOTA_EXCEEDED` | Budget/spending limit exceeded |
| `RESTRICTED` | Method not allowed for this connection |
| `UNAUTHORIZED` | Invalid or expired credentials |
| `INTERNAL` | Internal wallet error |
| `UNSUPPORTED_ENCRYPTION`| Requested encryption not supported |
| `BAD_REQUEST` | Malformed request parameters |
| `NOT_FOUND` | Invoice or resource not found |
| `EXPIRED` | Connection or invoice expired |
| `OTHER` | Unspecified error |
## Encryption
NWC supports two encryption schemes:
- **NIP-04** (default): Set `useNip44 = false` in event builders
- **NIP-44 v2**: Set `useNip44 = true` in event builders
When building requests with NIP-44, the event includes an `encryption` tag:
```
["encryption", "nip44_v2"]
```
Clients can check a wallet's supported encryption via the info event:
```kotlin
val infoEvent: NwcInfoEvent = ...
val schemes: List<String> = infoEvent.encryptionSchemes()
// e.g., ["nip04", "nip44_v2"]
```
## Caching
For apps handling many concurrent NWC events, use the built-in LRU caches:
```kotlin
val requestCache = NostrWalletConnectRequestCache(signer)
val responseCache = NostrWalletConnectResponseCache(signer)
// These cache up to 50 decrypted results and handle
// async retry logic for permission dialogs and timeouts.
val request: Request? = requestCache.decryptRequest(requestEvent)
val response: Response? = responseCache.decryptResponse(responseEvent)
```
## Amounts
All amounts in NWC are in **millisatoshis** (1 sat = 1000 msats). Convert for
display:
```kotlin
val balanceMsats = response.result?.balance ?: 0L
val balanceSats = balanceMsats / 1000L
```
## Interoperability
This implementation is tested against:
- **Alby Hub** (server) — uppercase transaction states, all error codes
- **Alby JS SDK** (client) — lowercase transaction states, budget renewal
periods, structured metadata
See `AlbyInteropTest.kt` for real-world test vectors.