diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt new file mode 100644 index 000000000..5686fc1d5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Client.kt @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal + +/** + * High-level NIP-47 Wallet Connect client. + * + * Simplifies the NWC protocol by handling URI parsing, signer creation, + * event building, filter construction, and response decryption. + * + * Usage: + * ```kotlin + * val client = Nip47Client.fromUri("nostr+walletconnect://pubkey?relay=...&secret=...") + * + * // Build a request event + * val requestEvent = client.payInvoice("lnbc50n1...") + * + * // Send requestEvent to client.relayUrl via your relay connection + * // Subscribe using client.responseFilter(requestEvent.id) for the response + * + * // When response arrives: + * val response = client.parseResponse(responseEvent) + * when (response) { + * is PayInvoiceSuccessResponse -> println("Paid! Preimage: ${response.result?.preimage}") + * is NwcErrorResponse -> println("Error: ${response.error?.message}") + * } + * ``` + */ +class Nip47Client( + val walletPubKeyHex: HexKey, + val relayUrl: NormalizedRelayUrl, + val signer: NostrSigner, + val useNip44: Boolean = false, +) { + companion object { + /** + * Creates an Nip47Client from a NWC connection URI string. + * + * @param uri NWC URI (e.g., "nostr+walletconnect://pubkey?relay=...&secret=...") + * @throws IllegalArgumentException if the URI is invalid or has no secret + */ + fun fromUri(uri: String): Nip47Client { + val config = Nip47WalletConnect.parse(uri) + return fromNip47URI(config) + } + + /** + * Creates an Nip47Client from parsed NWC connection details. + * + * @param config parsed NWC URI with wallet pubkey, relay, and secret + * @throws IllegalArgumentException if config has no secret + */ + fun fromNip47URI(config: Nip47WalletConnect.Nip47URINorm): Nip47Client { + val secret = config.secret ?: throw IllegalArgumentException("NWC connection requires a secret") + val signer = NostrSignerInternal(KeyPair(secret.hexToByteArray())) + return Nip47Client( + walletPubKeyHex = config.pubKeyHex, + relayUrl = config.relayUri, + signer = signer, + ) + } + } + + // --- Request builders --- + + /** + * Builds a pay_invoice request event. + */ + suspend fun payInvoice( + bolt11: String, + amount: Long? = null, + ): LnZapPaymentRequestEvent = + buildRequest( + if (amount != null) { + PayInvoiceMethod.create(bolt11, amount) + } else { + PayInvoiceMethod.create(bolt11) + }, + ) + + /** + * Builds a pay_keysend request event. + */ + suspend fun payKeysend( + amount: Long, + pubkey: String, + preimage: String? = null, + tlvRecords: List? = null, + ): LnZapPaymentRequestEvent = buildRequest(PayKeysendMethod.create(amount, pubkey, preimage, tlvRecords)) + + /** + * Builds a get_balance request event. + */ + suspend fun getBalance(): LnZapPaymentRequestEvent = buildRequest(GetBalanceMethod.create()) + + /** + * Builds a get_info request event. + */ + suspend fun getInfo(): LnZapPaymentRequestEvent = buildRequest(GetInfoMethod.create()) + + /** + * Builds a make_invoice request event. + */ + suspend fun makeInvoice( + amount: Long, + description: String? = null, + descriptionHash: String? = null, + expiry: Long? = null, + ): LnZapPaymentRequestEvent = buildRequest(MakeInvoiceMethod.create(amount, description, descriptionHash, expiry)) + + /** + * Builds a lookup_invoice request event by payment hash. + */ + suspend fun lookupInvoiceByHash(paymentHash: String): LnZapPaymentRequestEvent = + buildRequest(LookupInvoiceMethod.createByHash(paymentHash)) + + /** + * Builds a lookup_invoice request event by BOLT11 invoice. + */ + suspend fun lookupInvoiceByInvoice(invoice: String): LnZapPaymentRequestEvent = + buildRequest(LookupInvoiceMethod.createByInvoice(invoice)) + + /** + * Builds a list_transactions request event. + */ + suspend fun listTransactions( + from: Long? = null, + until: Long? = null, + limit: Int? = null, + offset: Int? = null, + unpaid: Boolean? = null, + type: String? = null, + ): LnZapPaymentRequestEvent = buildRequest(ListTransactionsMethod.create(from, until, limit, offset, unpaid, type)) + + /** + * Builds a get_budget request event. + */ + suspend fun getBudget(): LnZapPaymentRequestEvent = buildRequest(GetBudgetMethod.create()) + + /** + * Builds a sign_message request event. + */ + suspend fun signMessage(message: String): LnZapPaymentRequestEvent = buildRequest(SignMessageMethod.create(message)) + + /** + * Builds a make_hold_invoice request event. + */ + suspend fun makeHoldInvoice( + amount: Long, + paymentHash: String, + description: String? = null, + descriptionHash: String? = null, + expiry: Long? = null, + minCltvExpiryDelta: Int? = null, + ): LnZapPaymentRequestEvent = + buildRequest(MakeHoldInvoiceMethod.create(amount, paymentHash, description, descriptionHash, expiry, minCltvExpiryDelta)) + + /** + * Builds a cancel_hold_invoice request event. + */ + suspend fun cancelHoldInvoice(paymentHash: String): LnZapPaymentRequestEvent = + buildRequest(CancelHoldInvoiceMethod.create(paymentHash)) + + /** + * Builds a settle_hold_invoice request event. + */ + suspend fun settleHoldInvoice(preimage: String): LnZapPaymentRequestEvent = + buildRequest(SettleHoldInvoiceMethod.create(preimage)) + + /** + * Builds a request event from any [Request] object. + * This is the low-level method used by all convenience methods above. + */ + suspend fun buildRequest(request: Request): LnZapPaymentRequestEvent = + LnZapPaymentRequestEvent.createRequest( + request = request, + walletServicePubkey = walletPubKeyHex, + signer = signer, + useNip44 = useNip44, + ) + + // --- Response handling --- + + /** + * Decrypts and parses a response event from the wallet. + */ + suspend fun parseResponse(event: LnZapPaymentResponseEvent): Response = event.decrypt(signer) + + /** + * Decrypts and parses a notification event from the wallet. + */ + suspend fun parseNotification(event: NwcNotificationEvent): Notification = event.decryptNotification(signer) + + // --- Filter helpers --- + + /** + * Creates a filter to subscribe for responses to a specific request. + * Use this to subscribe on [relayUrl] after sending a request event. + */ + fun responseFilter(requestEventId: HexKey): Filter = + Filter( + kinds = listOf(LnZapPaymentResponseEvent.KIND), + authors = listOf(walletPubKeyHex), + tags = mapOf("e" to listOf(requestEventId)), + ) + + /** + * Creates a filter to subscribe for all responses from the wallet + * directed to this client. + */ + fun allResponsesFilter(since: Long? = null): Filter = + Filter( + kinds = listOf(LnZapPaymentResponseEvent.KIND), + authors = listOf(walletPubKeyHex), + tags = mapOf("p" to listOf(signer.pubKey)), + since = since, + ) + + /** + * Creates a filter to subscribe for wallet notifications. + */ + fun notificationsFilter(since: Long? = null): Filter = + Filter( + kinds = listOf(NwcNotificationEvent.KIND, NwcNotificationEvent.LEGACY_KIND), + authors = listOf(walletPubKeyHex), + tags = mapOf("p" to listOf(signer.pubKey)), + since = since, + ) + + /** + * Creates a filter to fetch the wallet's info event (kind 13194). + */ + fun infoFilter(): Filter = + Filter( + kinds = listOf(NwcInfoEvent.KIND), + authors = listOf(walletPubKeyHex), + limit = 1, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt new file mode 100644 index 000000000..6b032f3ae --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47Server.kt @@ -0,0 +1,294 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip47WalletConnect + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner + +/** + * High-level NIP-47 Wallet Connect server (wallet service). + * + * Simplifies building a wallet service that receives NWC requests from clients, + * processes them, and sends back responses and notifications. + * + * Usage: + * ```kotlin + * val server = Nip47Server(walletSigner, supportedMethods, relayUrl) + * + * // Publish capabilities + * val infoEvent = server.buildInfoEvent() + * // Send infoEvent to relay + * + * // Subscribe using server.requestsFilter() on your relay + * + * // When a request arrives: + * val request = server.parseRequest(requestEvent) + * when (request) { + * is GetBalanceMethod -> { + * val response = server.respondGetBalance(requestEvent, balance = 2100000L) + * // Send response to relay + * } + * is PayInvoiceMethod -> { + * // Process payment, then: + * val response = server.respondPayInvoice(requestEvent, preimage = "abc123") + * // Or on error: + * val error = server.respondError(requestEvent, NwcErrorCode.PAYMENT_FAILED, "Route not found") + * // Send response to relay + * } + * } + * ``` + */ +class Nip47Server( + val signer: NostrSigner, + val capabilities: List = emptyList(), + val useNip44: Boolean = false, + val encryptionSchemes: List? = null, + val notificationTypes: List? = null, +) { + // --- Info event --- + + /** + * Builds a kind 13194 info event advertising wallet capabilities. + * Sign and publish this event to your relay. + */ + fun buildInfoEvent() = + NwcInfoEvent.build( + capabilities = capabilities, + encryptionSchemes = encryptionSchemes, + notificationTypes = notificationTypes, + ) + + // --- Request parsing --- + + /** + * Decrypts and parses an incoming client request. + */ + suspend fun parseRequest(event: LnZapPaymentRequestEvent): Request = event.decryptRequest(signer) + + // --- Response builders --- + + /** + * Builds a response event from any [Response] object. + */ + suspend fun buildResponse( + response: Response, + requestEvent: LnZapPaymentRequestEvent, + ): LnZapPaymentResponseEvent = + LnZapPaymentResponseEvent.createResponse( + response = response, + requestEvent = requestEvent, + signer = signer, + useNip44 = useNip44, + ) + + /** + * Builds an error response for any method. + */ + suspend fun respondError( + requestEvent: LnZapPaymentRequestEvent, + code: NwcErrorCode, + message: String, + resultType: String? = null, + ): LnZapPaymentResponseEvent { + val method = resultType ?: requestEvent.decryptRequest(signer).method ?: NwcMethod.PAY_INVOICE + return buildResponse(NwcErrorResponse(method, NwcError(code, message)), requestEvent) + } + + /** + * Builds a pay_invoice success response. + */ + suspend fun respondPayInvoice( + requestEvent: LnZapPaymentRequestEvent, + preimage: String? = null, + feesPaid: Long? = null, + ): LnZapPaymentResponseEvent = + buildResponse( + PayInvoiceSuccessResponse(PayInvoiceSuccessResponse.PayInvoiceResultParams(preimage, feesPaid)), + requestEvent, + ) + + /** + * Builds a pay_keysend success response. + */ + suspend fun respondPayKeysend( + requestEvent: LnZapPaymentRequestEvent, + preimage: String? = null, + feesPaid: Long? = null, + ): LnZapPaymentResponseEvent = + buildResponse( + PayKeysendSuccessResponse(PayKeysendSuccessResponse.PayKeysendResult(preimage, feesPaid)), + requestEvent, + ) + + /** + * Builds a get_balance success response. + */ + suspend fun respondGetBalance( + requestEvent: LnZapPaymentRequestEvent, + balance: Long, + ): LnZapPaymentResponseEvent = + buildResponse( + GetBalanceSuccessResponse(GetBalanceSuccessResponse.GetBalanceResult(balance)), + requestEvent, + ) + + /** + * Builds a get_info success response. + */ + suspend fun respondGetInfo( + requestEvent: LnZapPaymentRequestEvent, + alias: String? = null, + color: String? = null, + pubkey: String? = null, + network: String? = null, + blockHeight: Long? = null, + blockHash: String? = null, + methods: List? = null, + notifications: List? = null, + lud16: String? = null, + ): LnZapPaymentResponseEvent = + buildResponse( + GetInfoSuccessResponse( + GetInfoSuccessResponse.GetInfoResult( + alias, color, pubkey, network, blockHeight, blockHash, methods, notifications, null, lud16, + ), + ), + requestEvent, + ) + + /** + * Builds a make_invoice success response. + */ + suspend fun respondMakeInvoice( + requestEvent: LnZapPaymentRequestEvent, + transaction: NwcTransaction, + ): LnZapPaymentResponseEvent = + buildResponse(MakeInvoiceSuccessResponse(transaction), requestEvent) + + /** + * Builds a lookup_invoice success response. + */ + suspend fun respondLookupInvoice( + requestEvent: LnZapPaymentRequestEvent, + transaction: NwcTransaction, + ): LnZapPaymentResponseEvent = + buildResponse(LookupInvoiceSuccessResponse(transaction), requestEvent) + + /** + * Builds a list_transactions success response. + */ + suspend fun respondListTransactions( + requestEvent: LnZapPaymentRequestEvent, + transactions: List, + totalCount: Long? = null, + ): LnZapPaymentResponseEvent = + buildResponse( + ListTransactionsSuccessResponse( + ListTransactionsSuccessResponse.ListTransactionsResult(transactions, totalCount), + ), + requestEvent, + ) + + /** + * Builds a get_budget success response. + */ + suspend fun respondGetBudget( + requestEvent: LnZapPaymentRequestEvent, + usedBudget: Long? = null, + totalBudget: Long? = null, + renewsAt: Long? = null, + renewalPeriod: String? = null, + ): LnZapPaymentResponseEvent = + buildResponse( + GetBudgetSuccessResponse( + GetBudgetSuccessResponse.GetBudgetResult(usedBudget, totalBudget, renewsAt, renewalPeriod), + ), + requestEvent, + ) + + /** + * Builds a sign_message success response. + */ + suspend fun respondSignMessage( + requestEvent: LnZapPaymentRequestEvent, + message: String, + signature: String, + ): LnZapPaymentResponseEvent = + buildResponse( + SignMessageSuccessResponse(SignMessageSuccessResponse.SignMessageResult(message, signature)), + requestEvent, + ) + + // --- Notification builders --- + + /** + * Builds a payment_received notification event. + */ + suspend fun notifyPaymentReceived( + clientPubkey: HexKey, + transaction: NwcTransaction, + ): NwcNotificationEvent = + NwcNotificationEvent.createNotification( + notification = PaymentReceivedNotification(transaction), + clientPubkey = clientPubkey, + signer = signer, + ) + + /** + * Builds a payment_sent notification event. + */ + suspend fun notifyPaymentSent( + clientPubkey: HexKey, + transaction: NwcTransaction, + ): NwcNotificationEvent = + NwcNotificationEvent.createNotification( + notification = PaymentSentNotification(transaction), + clientPubkey = clientPubkey, + signer = signer, + ) + + /** + * Builds a notification event from any [Notification] object. + */ + suspend fun buildNotification( + notification: Notification, + clientPubkey: HexKey, + ): NwcNotificationEvent = + NwcNotificationEvent.createNotification( + notification = notification, + clientPubkey = clientPubkey, + signer = signer, + ) + + // --- Filter helpers --- + + /** + * Creates a filter to subscribe for incoming client requests. + * Use this to subscribe on your relay. + */ + fun requestsFilter(since: Long? = null): Filter = + Filter( + kinds = listOf(LnZapPaymentRequestEvent.KIND), + tags = mapOf("p" to listOf(signer.pubKey)), + since = since, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md index 95907efa9..0d372e1a4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/README.md @@ -4,10 +4,86 @@ Quartz implementation of [NIP-47](https://github.com/nostr-protocol/nips/blob/ma Wallet Connect (NWC). This module provides everything needed to build both **wallet client apps** (like Amethyst) and **wallet service backends** (like Alby Hub). +## Quick Start — Wallet Client + +Use `Nip47Client` for a high-level API that handles URI parsing, signer creation, +event building, filter construction, and response decryption: + +```kotlin +// 1. Create client from NWC URI +val client = Nip47Client.fromUri("nostr+walletconnect://pubkey?relay=...&secret=...") + +// 2. Build request events — one method per NWC command +val payEvent = client.payInvoice("lnbc50n1...") +val balanceEvent = client.getBalance() +val infoEvent = client.getInfo() +val invoiceEvent = client.makeInvoice(amount = 50000L, description = "Coffee") +val txEvent = client.listTransactions(limit = 20) + +// 3. Send event to client.relayUrl via your relay connection +// 4. Subscribe using client.responseFilter(payEvent.id) for the response + +// 5. When response arrives, parse it +val response = client.parseResponse(responseEvent) +when (response) { + is PayInvoiceSuccessResponse -> println("Paid! Preimage: ${response.result?.preimage}") + is GetBalanceSuccessResponse -> println("Balance: ${response.result?.balance} msats") + is NwcErrorResponse -> println("Error: ${response.error?.message}") +} + +// Filter helpers for relay subscriptions +val filter = client.responseFilter(payEvent.id) // Filter for a specific response +val allFilter = client.allResponsesFilter() // Filter for all responses +val notifFilter = client.notificationsFilter() // Filter for notifications +val walletInfo = client.infoFilter() // Filter for wallet info event +``` + +## Quick Start — Wallet Service + +Use `Nip47Server` to build a wallet service that receives requests and sends responses: + +```kotlin +// 1. Create server +val server = Nip47Server( + signer = walletSigner, + capabilities = listOf(NwcMethod.PAY_INVOICE, NwcMethod.GET_BALANCE, NwcMethod.GET_INFO), +) + +// 2. Publish capabilities (kind 13194) +val infoTemplate = server.buildInfoEvent() +// Sign and send: walletSigner.sign(infoTemplate) + +// 3. Subscribe using server.requestsFilter() on your relay + +// 4. When a request arrives, parse and respond +val request = server.parseRequest(requestEvent) +when (request) { + is GetBalanceMethod -> { + val response = server.respondGetBalance(requestEvent, balance = 2100000L) + // Send response to relay + } + is PayInvoiceMethod -> { + // Process payment, then: + val response = server.respondPayInvoice(requestEvent, preimage = "abc123") + // Or on error: + val error = server.respondError(requestEvent, NwcErrorCode.PAYMENT_FAILED, "Route not found") + } + is MakeInvoiceMethod -> { + val tx = NwcTransaction(type = NwcTransactionType.INCOMING, invoice = "lnbc...") + val response = server.respondMakeInvoice(requestEvent, tx) + } +} + +// 5. Send notifications +val notifEvent = server.notifyPaymentReceived(clientPubkey, transaction) +``` + ## Architecture ``` nip47WalletConnect/ +├── Nip47Client.kt # High-level client API (URI → requests → responses) +├── Nip47Server.kt # High-level server API (requests → responses → notifications) ├── Nip47WalletConnect.kt # URI parsing (nostr+walletconnect://) ├── Request.kt # All 13 NWC request methods + params ├── Response.kt # All response types (success + error) @@ -38,49 +114,42 @@ nip47WalletConnect/ ## 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`| +| Method | `Nip47Client` method | Request Class | Success Response Class | +|----------------------|-----------------------------|---------------------------|-----------------------------------| +| `pay_invoice` | `payInvoice()` | `PayInvoiceMethod` | `PayInvoiceSuccessResponse` | +| `pay_keysend` | `payKeysend()` | `PayKeysendMethod` | `PayKeysendSuccessResponse` | +| `make_invoice` | `makeInvoice()` | `MakeInvoiceMethod` | `MakeInvoiceSuccessResponse` | +| `lookup_invoice` | `lookupInvoiceByHash/ByInvoice()` | `LookupInvoiceMethod`| `LookupInvoiceSuccessResponse` | +| `list_transactions` | `listTransactions()` | `ListTransactionsMethod` | `ListTransactionsSuccessResponse` | +| `get_balance` | `getBalance()` | `GetBalanceMethod` | `GetBalanceSuccessResponse` | +| `get_info` | `getInfo()` | `GetInfoMethod` | `GetInfoSuccessResponse` | +| `get_budget` | `getBudget()` | `GetBudgetMethod` | `GetBudgetSuccessResponse` | +| `sign_message` | `signMessage()` | `SignMessageMethod` | `SignMessageSuccessResponse` | +| `create_connection` | `buildRequest()` | `CreateConnectionMethod` | `CreateConnectionSuccessResponse` | +| `make_hold_invoice` | `makeHoldInvoice()` | `MakeHoldInvoiceMethod` | `MakeHoldInvoiceSuccessResponse` | +| `cancel_hold_invoice`| `cancelHoldInvoice()` | `CancelHoldInvoiceMethod` | `CancelHoldInvoiceSuccessResponse`| +| `settle_hold_invoice`| `settleHoldInvoice()` | `SettleHoldInvoiceMethod` | `SettleHoldInvoiceSuccessResponse`| Any method can also return `NwcErrorResponse` or (for `pay_invoice`) `PayInvoiceErrorResponse`. -## Implementing a Wallet Client +## Low-Level API -A wallet client connects to a user's lightning wallet to send payments, check -balances, create invoices, and list transactions. +The high-level `Nip47Client` and `Nip47Server` classes wrap the lower-level event +builders. You can use these directly if you need more control. -### 1. Parse the NWC Connection URI +### Wallet Client (Low-Level) -Users provide an NWC connection string from their wallet provider: +#### 1. Parse the NWC Connection URI ```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: +#### 2. Create the Client Signer ```kotlin val clientSigner = NostrSignerInternal( @@ -88,12 +157,9 @@ val clientSigner = NostrSignerInternal( ) ``` -### 3. Build and Send Requests - -Use `LnZapPaymentRequestEvent.createRequest()` to create encrypted request events: +#### 3. Build and Send Requests ```kotlin -// Get balance val balanceRequest = GetBalanceMethod.create() val event = LnZapPaymentRequestEvent.createRequest( request = balanceRequest, @@ -101,36 +167,6 @@ val event = LnZapPaymentRequestEvent.createRequest( 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: @@ -144,202 +180,69 @@ val event = LnZapPaymentRequestEvent.createRequest( ) ``` -### 4. Receive and Parse Responses +#### 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: +service pubkey and the request event ID: ```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 + val balanceSats = (response.result?.balance ?: 0L) / 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 = 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: +#### 5. Listen for Notifications ```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 +### Wallet Service (Low-Level) -Transaction states from different wallet implementations may use different -casing. Use the case-insensitive helpers: +#### 1. Publish Capabilities ```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, + capabilities = listOf(NwcMethod.PAY_INVOICE, NwcMethod.GET_BALANCE), encryptionSchemes = listOf("nip04", "nip44_v2"), - notificationTypes = listOf( - NwcNotificationType.PAYMENT_RECEIVED, - NwcNotificationType.PAYMENT_SENT, - ), + notificationTypes = listOf(NwcNotificationType.PAYMENT_RECEIVED), ) // 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: +#### 2. Parse Requests and Build Responses ```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 +// Build response val balanceResponse = GetBalanceSuccessResponse( - GetBalanceSuccessResponse.GetBalanceResult(balance = 2100000L) // in millisats + GetBalanceSuccessResponse.GetBalanceResult(balance = 2100000L) ) 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( @@ -353,60 +256,46 @@ val errorEvent = LnZapPaymentResponseEvent.createResponse( ) ``` -To use NIP-44 encryption for responses: +#### 3. Send Notifications ```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, + notification = PaymentReceivedNotification( + notification = NwcTransaction( + type = NwcTransactionType.INCOMING, + state = NwcTransactionState.SETTLED, + invoice = "lnbc...", + amount = 50000L, + payment_hash = "abc123", + settled_at = TimeUtils.now(), + created_at = TimeUtils.now(), + ), + ), clientPubkey = clientPubkeyHex, signer = walletSigner, ) -// Send notifEvent to the relay ``` -### 5. Build Transaction Objects +## Transaction State Helpers -Transactions are used across multiple response types: +Transaction states from different wallet implementations may use different +casing. Use the case-insensitive helpers: ```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, -) +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" +``` + +## URI Persistence + +```kotlin +// Save +val json = Nip47WalletConnect.Nip47URI.serializer(nwcConfig.denormalize()!!) + +// Restore +val restored = Nip47WalletConnect.Nip47URI.parser(json).normalize()!! ``` ## Error Codes @@ -431,13 +320,8 @@ val transaction = NwcTransaction( 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"] -``` +- **NIP-04** (default): `Nip47Client(useNip44 = false)` or `useNip44 = false` in event builders +- **NIP-44 v2**: `Nip47Client(useNip44 = true)` or `useNip44 = true` in event builders Clients can check a wallet's supported encryption via the info event: ```kotlin @@ -454,8 +338,6 @@ For apps handling many concurrent NWC events, use the built-in LRU caches: 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) ```