From b1de0f01186ec9c537a28546295b40ec9e7ebe5e Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Fri, 25 Apr 2025 22:11:01 +0200 Subject: [PATCH] Feature: NWC Implementation (#95) * feat: add Alby JS SDK documentation and update package dependencies * feat: add NostrWalletConnect component and ProfileSettingsNWCPage for wallet integration * add NWC link to AvatarDropdown menu * store and load nwc from localstorage * feat: add NIP-47 documentation for Nostr Wallet Connect protocol * feat: integrate Nostr Wallet Connect for zap payments and enhance UI feedback * fix: update default custom amount to 21 and improve UI feedback for payment status on nwc * fix: correct typo in QR code payment instruction text --------- Co-authored-by: highperfocused --- .../prompts/alby-js-sdk-nwc-client.prompt.md | 253 ++++++++ .github/prompts/alby-js-sdk.prompt.md | 112 ++++ .github/prompts/nostr-nip47.prompt.md | 524 +++++++++++++++++ app/profile/settings/nwc/page.tsx | 42 ++ components/NostrWalletConnect.tsx | 259 +++++++++ components/ZapButton.tsx | 542 ++++++++++++++---- .../headerComponents/AvatarDropdown.tsx | 5 + package-lock.json | 38 +- package.json | 1 + 9 files changed, 1655 insertions(+), 121 deletions(-) create mode 100644 .github/prompts/alby-js-sdk-nwc-client.prompt.md create mode 100644 .github/prompts/alby-js-sdk.prompt.md create mode 100644 .github/prompts/nostr-nip47.prompt.md create mode 100644 app/profile/settings/nwc/page.tsx create mode 100644 components/NostrWalletConnect.tsx diff --git a/.github/prompts/alby-js-sdk-nwc-client.prompt.md b/.github/prompts/alby-js-sdk-nwc-client.prompt.md new file mode 100644 index 0000000..2664e5d --- /dev/null +++ b/.github/prompts/alby-js-sdk-nwc-client.prompt.md @@ -0,0 +1,253 @@ +# Nostr Wallet Connect Documentation + +[Nostr Wallet Connect](https://nwc.dev) is an open protocol enabling applications to interact with bitcoin lightning wallets. It allows users to connect their existing wallets to your application allowing developers to easily integrate bitcoin lightning functionality. + +The Alby JS SDK allows you to easily integrate Nostr Wallet Connect into any JavaScript based application. + +There are two interfaces you can use to access NWC: + +- The `NWCClient` exposes the [NWC](https://nwc.dev/) interface directly, which is more powerful than the WebLN interface and is recommended if you plan to create an application outside of the web (e.g. native mobile/command line/server backend etc.). You can explore all the examples [here](../examples/nwc/client/). +- The `NostrWebLNProvider` exposes the [WebLN](https://webln.guide/) interface to execute lightning wallet functionality through Nostr Wallet Connect, such as sending payments, making invoices and getting the node balance. You can explore all the examples [here](../examples/nwc/). See also [Bitcoin Connect](https://github.com/getAlby/bitcoin-connect/) if you are developing a frontend web application. + +> See [NWCClient class documentation](https://getalby.github.io/js-sdk/classes/nwc.NWCClient.html) + +## NWCClient + +### Initialization Options + +- `nostrWalletConnectUrl`: full Nostr Wallet Connect URL as defined by the [spec](https://github.com/getAlby/nips/blob/master/47.md) +- `relayUrl`: URL of the Nostr relay to be used (e.g. wss://relay.getalby.com/v1) +- `walletPubkey`: pubkey of the Nostr Wallet Connect app +- `secret`: secret key to sign the request event (if not available window.nostr will be used) + +### NWCClient Quick start example + +```js +import { nwc } from "@getalby/sdk"; +const nwcClient = new nwc.NWCClient({ + nostrWalletConnectUrl: loadNWCUrl(), +}); // loadNWCUrl is some function to get the NWC URL from some (encrypted) storage + +// now you can send payments by passing in the invoice in an object +const response = await nwcClient.payInvoice({ invoice }); +``` + +### `static fromAuthorizationUrl()` + +Initialized a new `NWCClient` instance but generates a new random secret. The pubkey of that secret then needs to be authorized by the user (this can be initiated by redirecting the user to the `getAuthorizationUrl()` URL or calling `fromAuthorizationUrl()` to open an authorization popup. + +```js +const nwcClient = await nwc.NWCClient.fromAuthorizationUrl( + "https://my.albyhub.com/apps/new", + { + name: "My app name", + }, +); +``` + +The same options can be provided to getAuthorizationUrl() as fromAuthorizationUrl() - see [Manual Auth example](../examples/nwc/client/auth_manual.html) + +### Examples + +See [the NWC client examples directory](../examples/nwc/client) for a full list of examples. + +## NostrWebLNProvider + +> See [NostrWebLNProvider class documentation](https://getalby.github.io/js-sdk/classes/webln.NostrWebLNProvider.html) + +### Initialization Options + +- `nostrWalletConnectUrl`: full Nostr Wallet Connect URL as defined by the [spec](https://github.com/getAlby/nips/blob/master/47.md) +- `relayUrl`: URL of the Nostr relay to be used (e.g. wss://relay.getalby.com/v1) +- `walletPubkey`: pubkey of the Nostr Wallet Connect app +- `secret`: secret key to sign the request event (if not available window.nostr will be used) +- `client`: initialize using an existing NWC client + +### WebLN Quick start example + +```js +import { webln } from "@getalby/sdk"; +const nwc = new webln.NostrWebLNProvider({ + nostrWalletConnectUrl: loadNWCUrl(), +}); // loadNWCUrl is some function to get the NWC URL from some (encrypted) storage +// or use the short version +const nwc = new webln.NWC({ nostrWalletConnectUrl: loadNWCUrl }); + +// connect to the relay +await nwc.enable(); + +// now you can send payments by passing in the invoice +const response = await nwc.sendPayment(invoice); +``` + +You can use NWC as a webln compatible object in your web app: + +```js +// you can set the window.webln object to use the universal API to send payments: +if (!window.webln) { + // prompt the user to connect to NWC + window.webln = new webln.NostrWebLNProvider({ + nostrWalletConnectUrl: loadNWCUrl, + }); + // now use any webln code +} +``` + +## NostrWebLNProvider Functions + +The goal of the Nostr Wallet Connect provider is to be API compatible with [webln](https://www.webln.guide/). Currently not all methods are supported - see the examples/nwc directory for a list of supported methods. + +### sendPayment(invoice: string) + +Takes a bolt11 invoice and calls the NWC `pay_invoice` function. +It returns a promise object that is resolved with an object with the preimage or is rejected with an error + +#### Payment Example + +```js +import { webln } from "@getalby/sdk"; +const nwc = new webln.NostrWebLNProvider({ nostrWalletConnectUrl: loadNWCUrl }); +await nwc.enable(); +const response = await nwc.sendPayment(invoice); +console.log(response); +``` + +#### getNostrWalletConnectUrl() + +Returns the `nostr+walletconnect://` URL which includes all the connection information (`walletPubkey`, `relayUrl`, `secret`) +This can be used to get and persist the string for later use. + +#### fromAuthorizationUrl(url: string, {name: string}) + +Opens a new window prompt with at the provided authorization URL to ask the user to authorize the app connection. +The promise resolves when the connection is authorized and the popup sends a `nwc:success` message or rejects when the prompt is closed. +Pass a `name` to the NWC provider describing the application. + +```js +import { webln } from "@getalby/sdk"; + +try { + const nwc = await webln.NostrWebLNProvider.fromAuthorizationUrl( + "https://my.albyhub.com/apps/new", + { + name: "My app name", + }, + ); +} catch (e) { + console.error(e); +} +await nwc.enable(); +let response; +try { + response = await nwc.sendPayment(invoice); + // if success then the response.preimage will be only + console.info(`payment successful, the preimage is ${response.preimage}`); +} catch (e) { + console.error(e.error || e); +} +``` + +#### React Native (Expo) + +Look at our [NWC React Native Expo Demo app](https://github.com/getAlby/nwc-react-native-expo) for how to use NWC in a React Native expo project. + +#### For Node.js + +To use this on Node.js you first must install `websocket-polyfill@0.0.3` and import it: + +```js +import "websocket-polyfill"; +// or: require('websocket-polyfill'); +``` + +if you get an `crypto is not defined` error, either upgrade to node.js 20 or above, or import it manually: + +```js +import * as crypto from 'crypto'; // or 'node:crypto' +globalThis.crypto = crypto as any; +//or: global.crypto = require('crypto'); +``` + +### Examples + +#### Defaults + +```js +import { webln } from "@getalby/sdk"; + +const nwc = new webln.NostrWebLNProvider(); // use defaults (connects to Alby's relay, will use window.nostr to sign the request) +await nwc.enable(); // connect to the relay +const response = await nwc.sendPayment(invoice); +console.log(response.preimage); + +nwc.close(); // close the websocket connection +``` + +#### Use a custom, user provided Nostr Wallet Connect URL + +```js +import { webln } from "@getalby/sdk"; + +const nwc = new webln.NostrWebLNProvider({ + nostrWalletConnectUrl: + "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=wss://nostr.bitcoiner.social&secret=c60320b3ecb6c15557510d1518ef41194e9f9337c82621ddef3f979f668bfebd", +}); // use defaults +await nwc.enable(); // connect to the relay +const response = await nwc.sendPayment(invoice); +console.log(response.preimage); + +nwc.close(); // close the websocket connection +``` + +#### Generate a new NWC connect url using a locally-generated secret + +```js +// use the `fromAuthorizationUrl` helper which opens a popup to initiate the connection flow. +// the promise resolves once the NWC app returned. +const nwc = await webln.NostrWebLNProvider.fromAuthorizationUrl( + "https://my.albyhub.com/apps/new", + { + name: "My app name", + }, +); + +// ... enable and send a payment + +// if you want to get the connect url with the secret: +// const nostrWalletConnectUrl nwc.getNostrWalletConnectUrl(true) +``` + +The same options can be provided to getAuthorizationUrl() as fromAuthorizationUrl() - see [Manual Auth example](../examples/nwc/auth_manual.html) + +### Nostr Wallet Auth + +NWA is an alternative flow for lightning apps to easily initialize an NWC connection to mobile-first or self-custodial wallets, using a client-created secret. + +The app will generate an NWA URI which should be opened in the wallet, where the user can approve the connection. + +> See [NWAClient class documentation](https://getalby.github.io/js-sdk/classes/nwc.NWAClient.html) + +#### Generating an NWA URI (For Client apps) + +```js +import { nwa } from "@getalby/sdk"; +const connectionUri = new nwa.NWAClient({ + relayUrl, + requestMethods: ["get_info"], +}).connectionUri; + +// then allow the user to copy it / display it as a QR code to the user +``` + +See full [NWA example](../examples/nwc/client/nwa.js) + +### Accepting and creating a connection from an NWA URI (For Wallet services) + +```js +import { nwa } from "@getalby/sdk"; +const nwaOptions = nwa.NWAClient.parseWalletAuthUrl(nwaUrl); + +// then use `nwaOptions` to display a confirmation page to the user and create a connection. +``` + +See full [NWA accept example](../examples/nwc/client/nwa-accept.js) for NWA URI parsing and handling. The implementation of actually creating the connection and showing a confirmation page to the user is wallet-specific. In the example, a connection will be created via the `create_connection` NWC command. \ No newline at end of file diff --git a/.github/prompts/alby-js-sdk.prompt.md b/.github/prompts/alby-js-sdk.prompt.md new file mode 100644 index 0000000..dcdfaa7 --- /dev/null +++ b/.github/prompts/alby-js-sdk.prompt.md @@ -0,0 +1,112 @@ +# Alby JS SDK + +## Introduction + +Build zero-custody bitcoin payments into apps with a few lines of code. + +This JavaScript SDK is for interacting with a bitcoin lightning wallet via Nostr Wallet Connect or the Alby Wallet API. + +## Installing + +```bash +npm install @getalby/sdk +``` + +or + +```bash +yarn add @getalby/sdk +``` + +or for use without any build tools: + +```html + +``` + +## Lightning Network Client (LN) Documentation + +Quickly get started adding lightning payments to your app. + +> The easiest way to provide credentials is with an [NWC connection secret](https://nwc.dev). Get one in minutes by connecting to [Alby Hub](https://albyhub.com/), [coinos](https://coinos.io/apps/new), [Primal](https://primal.net/downloads), [lnwallet.app](https://lnwallet.app/), [Yakihonne](https://yakihonne.com/), [or other NWC-enabled wallets](https://github.com/getAlby/awesome-nwc?tab=readme-ov-file#nwc-wallets). + +For example, to make a payment: + +```js +import { LN, USD } from "@getalby/sdk"; +const credentials = "nostr+walletconnect://..."; // the NWC connection credentials +await new LN(credentials).pay("lnbc..."); // pay a lightning invoice +await new LN(credentials).pay("hello@getalby.com", USD(1)); // or pay $1 USD to a lightning address +``` + +Or to request to receive a payment: + +```js +const request = await new LN(credentials).requestPayment(USD(1.0)); +// give request.invoice to someone... +request.onPaid(giveAccess); +``` + +[Read more](./docs/lnclient.md) + +For more flexibility you can access the underlying NWC wallet directly. Continue to read the Nostr Wallet Connect documentation below. + +## Nostr Wallet Connect Documentation + +[Nostr Wallet Connect](https://nwc.dev) is an open protocol enabling applications to interact with bitcoin lightning wallets. It allows users to connect their existing wallets to your application allowing developers to easily integrate bitcoin lightning functionality. + +For apps, see [NWC client and NWA client documentation](./docs/nwc.md) + +For wallet services, see [NWC wallet service documentation](./docs/nwc-wallet-service.md) + +## Alby Wallet API Documentation + +The [Alby OAuth API](https://guides.getalby.com/alby-wallet-api/reference/getting-started) allows you to integrate bitcoin lightning functionality provided by the Alby Wallet into your applications, with the Alby Wallet API. Send & receive payments, create invoices, setup payment webhooks, access Podcasting 2.0 and more! + +[Read more](./docs/oauth.md) + +### NodeJS + +#### Fetch + +**This library relies on a global fetch() function which will work in browsers and node v18.x or newer.** (In older versions you have to use a polyfill.) + +#### Websocket polyfill + +To use this on Node.js you first must install `websocket-polyfill@0.0.3` and import it: + +```js +import "websocket-polyfill"; +// or: require('websocket-polyfill'); +``` + +## WebLN Documentation + +The JS SDK also has some implementations for [WebLN](https://webln.guide). +See the [NostrWebLNProvider documentation](./docs/nwc.md) and [OAuthWebLNProvider documentation](./docs/oauth.md). + +## More Documentation + +Read the [auto-generated documentation](https://getalby.github.io/js-sdk/modules.html) + +## Need help? + +We are happy to help, please contact us or create an issue. + +- [Twitter: @getAlby](https://twitter.com/getAlby) +- [Telegram Community Chat](https://t.me/getAlby) +- e-mail to support@getalby.com +- [bitcoin.design](https://bitcoin.design/) Slack community [#lightning-browser-extension](https://bitcoindesign.slack.com/archives/C02591ADXM2) +- Read the [Alby developer guide](https://guides.getalby.com/developer-guide) to better understand how Alby packages and APIs can be used to power your app. + +## Thanks + +The client and the setup is inspired and based on the [twitter-api-typescript-sdk](https://github.com/twitterdev/twitter-api-typescript-sdk). + +## License + +MIT \ No newline at end of file diff --git a/.github/prompts/nostr-nip47.prompt.md b/.github/prompts/nostr-nip47.prompt.md new file mode 100644 index 0000000..77d52e8 --- /dev/null +++ b/.github/prompts/nostr-nip47.prompt.md @@ -0,0 +1,524 @@ +NIP-47 +====== + +Nostr Wallet Connect (NWC) +-------------------- + +`draft` `optional` + +## Rationale + +This NIP describes a way for clients to access a remote lightning wallet through a standardized protocol. Custodians may implement this, or the user may run a bridge that bridges their wallet/node and the Nostr Wallet Connect protocol. + +## Terms + +* **client**: Nostr app on any platform that wants to interact with a lightning wallet. +* **user**: The person using the **client**, and wants to connect their wallet to their **client**. +* **wallet service**: Nostr app that typically runs on an always-on computer (eg. in the cloud or on a Raspberry Pi). This app has access to the APIs of the wallets it serves. + +## Theory of Operation + +Fundamentally NWC is communication between a **client** and **wallet service** by the means of E2E-encrypted direct messages over a nostr relay. The relay knows the kinds and tags of notes, but not the content of the encrypted payloads. The **user**'s identity key is not used to avoid linking payment activity to the user. Ideally unique keys are used for each individual connection. + + 1. **Users** who wish to use this NIP to allow **client(s)** to interact with their wallet must first acquire a special "connection" URI from their NIP-47 compliant wallet application. The wallet application may provide this URI using a QR screen, or a pasteable string, or some other means. + + 2. The **user** should then copy this URI into their **client(s)** by pasting, or scanning the QR, etc. The **client(s)** should save this URI and use it later whenever the **user** (or the **client** on the user's behalf) wants to interact with the wallet. The **client** should then request an `info` (13194) event from the relay(s) specified in the URI. The **wallet service** will have sent that event to those relays earlier, and the relays will hold it as a replaceable event. + + 3. When the **user** initiates a payment their nostr **client** create a `pay_invoice` request, encrypts it using a token from the URI, and sends it (kind 23194) to the relay(s) specified in the connection URI. The **wallet service** will be listening on those relays and will decrypt the request and then contact the **user's** wallet application to send the payment. The **wallet service** will know how to talk to the wallet application because the connection URI specified relay(s) that have access to the wallet app API. + + 4. Once the payment is complete the **wallet service** will send an encrypted `response` (kind 23195) to the **user** over the relay(s) in the URI. + + 5. The **wallet service** may send encrypted notifications (kind 23196) of wallet events (such as a received payment) to the **client**. + +## Events + +There are four event kinds: +- `NIP-47 info event`: 13194 +- `NIP-47 request`: 23194 +- `NIP-47 response`: 23195 +- `NIP-47 notification event`: 23196 + +### Info Event + +The info event should be a replaceable event that is published by the **wallet service** on the relay to indicate which capabilities it supports. + +The content should be a plaintext string with the supported capabilities space-separated, eg. `pay_invoice get_balance notifications`. + +If the **wallet service** supports notifications, the info event SHOULD contain a `notifications` tag with the supported notification types space-separated, eg. `payment_received payment_sent`. + +### Request and Response Events + +Both the request and response events SHOULD contain one `p` tag, containing the public key of the **wallet service** if this is a request, and the public key of the **client** if this is a response. The response event SHOULD contain an `e` tag with the id of the request event it is responding to. +Optionally, a request can have an `expiration` tag that has a unix timestamp in seconds. If the request is received after this timestamp, it should be ignored. + +The content of requests and responses is encrypted with [NIP04](04.md), and is a JSON-RPCish object with a semi-fixed structure: + +Request: +```jsonc +{ + "method": "pay_invoice", // method, string + "params": { // params, object + "invoice": "lnbc50n1..." // command-related data + } +} +``` + +Response: +```jsonc +{ + "result_type": "pay_invoice", //indicates the structure of the result field + "error": { //object, non-null in case of error + "code": "UNAUTHORIZED", //string error code, see below + "message": "human readable error message" + }, + "result": { // result, object. null in case of error. + "preimage": "0123456789abcdef..." // command-related data + } +} +``` + +The `result_type` field MUST contain the name of the method that this event is responding to. +The `error` field MUST contain a `message` field with a human readable error message and a `code` field with the error code if the command was not successful. +If the command was successful, the `error` field must be null. + +### Notification Events + +The notification event SHOULD contain one `p` tag, the public key of the **client**. + +The content of notifications is encrypted with [NIP04](04.md), and is a JSON-RPCish object with a semi-fixed structure: + +```jsonc +{ + "notification_type": "payment_received", //indicates the structure of the notification field + "notification": { + "payment_hash": "0123456789abcdef..." // notification-related data + } +} +``` + + +### Error codes +- `RATE_LIMITED`: The client is sending commands too fast. It should retry in a few seconds. +- `NOT_IMPLEMENTED`: The command is not known or is intentionally not implemented. +- `INSUFFICIENT_BALANCE`: The wallet does not have enough funds to cover a fee reserve or the payment amount. +- `QUOTA_EXCEEDED`: The wallet has exceeded its spending quota. +- `RESTRICTED`: This public key is not allowed to do this operation. +- `UNAUTHORIZED`: This public key has no wallet connected. +- `INTERNAL`: An internal error. +- `OTHER`: Other error. + +## Nostr Wallet Connect URI + +Communication between the **client** and **wallet service** requires two keys in order to encrypt and decrypt messages. The connection URI includes the secret key of the **client** and only the public key of the **wallet service**. + +The **client** discovers **wallet service** by scanning a QR code, handling a deeplink or pasting in a URI. + +The **wallet service** generates this connection URI with protocol `nostr+walletconnect://` and base path its 32-byte hex-encoded `pubkey`, which SHOULD be unique per client connection. + +The connection URI contains the following query string parameters: + +- `relay` Required. URL of the relay where the **wallet service** is connected and will be listening for events. May be more than one. +- `secret` Required. 32-byte randomly generated hex encoded string. The **client** MUST use this to sign events and encrypt payloads when communicating with the **wallet service**. The **wallet service** MUST use the corresponding public key of this secret to communicate with the **client**. + - Authorization does not require passing keys back and forth. + - The user can have different keys for different applications. Keys can be revoked and created at will and have arbitrary constraints (eg. budgets). + - The key is harder to leak since it is not shown to the user and backed up. + - It improves privacy because the user's main key would not be linked to their payments. +- `lud16` Recommended. A lightning address that clients can use to automatically setup the `lud16` field on the user's profile if they have none configured. + +The **client** should then store this connection and use it when the user wants to perform actions like paying an invoice. Due to this NIP using ephemeral events, it is recommended to pick relays that do not close connections on inactivity to not drop events, and ideally retain the events until they are either consumed or become stale. + +- When the **client** sends or receives a message it will use the `secret` from the connection URI and **wallet service**'s `pubkey` to encrypt or decrypt. +- When the **wallet service** sends or receives a message it will use its own secret and the corresponding pubkey of the **client's** `secret` to encrypt or decrypt. The **wallet service** SHOULD NOT store the secret it generates for the client and MUST NOT rely on the knowing the **client** secret for general operation. + +### Example connection string +```sh +nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c +``` + +## Commands + +### `pay_invoice` + +Description: Requests payment of an invoice. + +Request: +```jsonc +{ + "method": "pay_invoice", + "params": { + "invoice": "lnbc50n1...", // bolt11 invoice + "amount": 123, // invoice amount in msats, optional + } +} +``` + +Response: +```jsonc +{ + "result_type": "pay_invoice", + "result": { + "preimage": "0123456789abcdef...", // preimage of the payment + "fees_paid": 123, // value in msats, optional + } +} +``` + +Errors: +- `PAYMENT_FAILED`: The payment failed. This may be due to a timeout, exhausting all routes, insufficient capacity or similar. + +### `multi_pay_invoice` + +Description: Requests payment of multiple invoices. + +Request: +```jsonc +{ + "method": "multi_pay_invoice", + "params": { + "invoices": [ + {"id":"4da52c32a1", "invoice": "lnbc1...", "amount": 123}, // bolt11 invoice and amount in msats, amount is optional + {"id":"3da52c32a1", "invoice": "lnbc50n1..."}, + ], + } +} +``` + +Response: + +For every invoice in the request, a separate response event is sent. To differentiate between the responses, each +response event contains a `d` tag with the id of the invoice it is responding to; if no id was given, then the +payment hash of the invoice should be used. + +```jsonc +{ + "result_type": "multi_pay_invoice", + "result": { + "preimage": "0123456789abcdef...", // preimage of the payment + "fees_paid": 123, // value in msats, optional + } +} +``` + +Errors: +- `PAYMENT_FAILED`: The payment failed. This may be due to a timeout, exhausting all routes, insufficient capacity or similar. + +### `pay_keysend` + +Request: +```jsonc +{ + "method": "pay_keysend", + "params": { + "amount": 123, // invoice amount in msats, required + "pubkey": "03...", // payee pubkey, required + "preimage": "0123456789abcdef...", // preimage of the payment, optional + "tlv_records": [ // tlv records, optional + { + "type": 5482373484, // tlv type + "value": "0123456789abcdef" // hex encoded tlv value + } + ] + } +} +``` + +Response: +```jsonc +{ + "result_type": "pay_keysend", + "result": { + "preimage": "0123456789abcdef...", // preimage of the payment + "fees_paid": 123, // value in msats, optional + } +} +``` + +Errors: +- `PAYMENT_FAILED`: The payment failed. This may be due to a timeout, exhausting all routes, insufficient capacity or similar. + +### `multi_pay_keysend` + +Description: Requests multiple keysend payments. + +Has an array of keysends, these follow the same semantics as `pay_keysend`, just done in a batch + +Request: +```jsonc +{ + "method": "multi_pay_keysend", + "params": { + "keysends": [ + {"id": "4c5b24a351", "pubkey": "03...", "amount": 123}, + {"id": "3da52c32a1", "pubkey": "02...", "amount": 567, "preimage": "abc123..", "tlv_records": [{"type": 696969, "value": "77616c5f6872444873305242454d353736"}]}, + ], + } +} +``` + +Response: + +For every keysend in the request, a separate response event is sent. To differentiate between the responses, each +response event contains a `d` tag with the id of the keysend it is responding to; if no id was given, then the +pubkey should be used. + +```jsonc +{ + "result_type": "multi_pay_keysend", + "result": { + "preimage": "0123456789abcdef...", // preimage of the payment + "fees_paid": 123, // value in msats, optional + } +} +``` + +Errors: +- `PAYMENT_FAILED`: The payment failed. This may be due to a timeout, exhausting all routes, insufficient capacity or similar. + +### `make_invoice` + +Request: +```jsonc +{ + "method": "make_invoice", + "params": { + "amount": 123, // value in msats + "description": "string", // invoice's description, optional + "description_hash": "string", // invoice's description hash, optional + "expiry": 213 // expiry in seconds from time invoice is created, optional + } +} +``` + +Response: +```jsonc +{ + "result_type": "make_invoice", + "result": { + "type": "incoming", // "incoming" for invoices, "outgoing" for payments + "invoice": "string", // encoded invoice, optional + "description": "string", // invoice's description, optional + "description_hash": "string", // invoice's description hash, optional + "preimage": "string", // payment's preimage, optional if unpaid + "payment_hash": "string", // Payment hash for the payment + "amount": 123, // value in msats + "fees_paid": 123, // value in msats + "created_at": unixtimestamp, // invoice/payment creation time + "expires_at": unixtimestamp, // invoice expiration time, optional if not applicable + "metadata": {} // generic metadata that can be used to add things like zap/boostagram details for a payer name/comment/etc. + } +} +``` + +### `lookup_invoice` + +Request: +```jsonc +{ + "method": "lookup_invoice", + "params": { + "payment_hash": "31afdf1..", // payment hash of the invoice, one of payment_hash or invoice is required + "invoice": "lnbc50n1..." // invoice to lookup + } +} +``` + +Response: +```jsonc +{ + "result_type": "lookup_invoice", + "result": { + "type": "incoming", // "incoming" for invoices, "outgoing" for payments + "invoice": "string", // encoded invoice, optional + "description": "string", // invoice's description, optional + "description_hash": "string", // invoice's description hash, optional + "preimage": "string", // payment's preimage, optional if unpaid + "payment_hash": "string", // Payment hash for the payment + "amount": 123, // value in msats + "fees_paid": 123, // value in msats + "created_at": unixtimestamp, // invoice/payment creation time + "expires_at": unixtimestamp, // invoice expiration time, optional if not applicable + "settled_at": unixtimestamp, // invoice/payment settlement time, optional if unpaid + "metadata": {} // generic metadata that can be used to add things like zap/boostagram details for a payer name/comment/etc. + } +} +``` + +Errors: +- `NOT_FOUND`: The invoice could not be found by the given parameters. + +### `list_transactions` + +Lists invoices and payments. If `type` is not specified, both invoices and payments are returned. +The `from` and `until` parameters are timestamps in seconds since epoch. If `from` is not specified, it defaults to 0. +If `until` is not specified, it defaults to the current time. Transactions are returned in descending order of creation +time. + +Request: +```jsonc +{ + "method": "list_transactions", + "params": { + "from": 1693876973, // starting timestamp in seconds since epoch (inclusive), optional + "until": 1703225078, // ending timestamp in seconds since epoch (inclusive), optional + "limit": 10, // maximum number of invoices to return, optional + "offset": 0, // offset of the first invoice to return, optional + "unpaid": true, // include unpaid invoices, optional, default false + "type": "incoming", // "incoming" for invoices, "outgoing" for payments, undefined for both + } +} +``` + +Response: +```jsonc +{ + "result_type": "list_transactions", + "result": { + "transactions": [ + { + "type": "incoming", // "incoming" for invoices, "outgoing" for payments + "invoice": "string", // encoded invoice, optional + "description": "string", // invoice's description, optional + "description_hash": "string", // invoice's description hash, optional + "preimage": "string", // payment's preimage, optional if unpaid + "payment_hash": "string", // Payment hash for the payment + "amount": 123, // value in msats + "fees_paid": 123, // value in msats + "created_at": unixtimestamp, // invoice/payment creation time + "expires_at": unixtimestamp, // invoice expiration time, optional if not applicable + "settled_at": unixtimestamp, // invoice/payment settlement time, optional if unpaid + "metadata": {} // generic metadata that can be used to add things like zap/boostagram details for a payer name/comment/etc. + } + ], + }, +} +``` + +### `get_balance` + +Request: +```jsonc +{ + "method": "get_balance", + "params": {} +} +``` + +Response: +```jsonc +{ + "result_type": "get_balance", + "result": { + "balance": 10000, // user's balance in msats + } +} +``` + +### `get_info` + +Request: +```jsonc +{ + "method": "get_info", + "params": {} +} +``` + +Response: +```jsonc +{ + "result_type": "get_info", + "result": { + "alias": "string", + "color": "hex string", + "pubkey": "hex string", + "network": "string", // mainnet, testnet, signet, or regtest + "block_height": 1, + "block_hash": "hex string", + "methods": ["pay_invoice", "get_balance", "make_invoice", "lookup_invoice", "list_transactions", "get_info"], // list of supported methods for this connection + "notifications": ["payment_received", "payment_sent"], // list of supported notifications for this connection, optional. + } +} +``` + +## Notifications + +### `payment_received` + +Description: A payment was successfully received by the wallet. + +Notification: +```jsonc +{ + "notification_type": "payment_received", + "notification": { + "type": "incoming", + "invoice": "string", // encoded invoice + "description": "string", // invoice's description, optional + "description_hash": "string", // invoice's description hash, optional + "preimage": "string", // payment's preimage + "payment_hash": "string", // Payment hash for the payment + "amount": 123, // value in msats + "fees_paid": 123, // value in msats + "created_at": unixtimestamp, // invoice/payment creation time + "expires_at": unixtimestamp, // invoice expiration time, optional if not applicable + "settled_at": unixtimestamp, // invoice/payment settlement time + "metadata": {} // generic metadata that can be used to add things like zap/boostagram details for a payer name/comment/etc. + } +} +``` + +### `payment_sent` + +Description: A payment was successfully sent by the wallet. + +Notification: +```jsonc +{ + "notification_type": "payment_sent", + "notification": { + "type": "outgoing", + "invoice": "string", // encoded invoice + "description": "string", // invoice's description, optional + "description_hash": "string", // invoice's description hash, optional + "preimage": "string", // payment's preimage + "payment_hash": "string", // Payment hash for the payment + "amount": 123, // value in msats + "fees_paid": 123, // value in msats + "created_at": unixtimestamp, // invoice/payment creation time + "expires_at": unixtimestamp, // invoice expiration time, optional if not applicable + "settled_at": unixtimestamp, // invoice/payment settlement time + "metadata": {} // generic metadata that can be used to add things like zap/boostagram details for a payer name/comment/etc. + } +} +``` + +## Example pay invoice flow + +0. The user scans the QR code generated by the **wallet service** with their **client** application, they follow a `nostr+walletconnect://` deeplink or configure the connection details manually. +1. **client** sends an event to the **wallet service** with kind `23194`. The content is a `pay_invoice` request. The private key is the secret from the connection string above. +2. **wallet service** verifies that the author's key is authorized to perform the payment, decrypts the payload and sends the payment. +3. **wallet service** responds to the event by sending an event with kind `23195` and content being a response either containing an error message or a preimage. + +## Using a dedicated relay +This NIP does not specify any requirements on the type of relays used. However, if the user is using a custodial service it might make sense to use a relay that is hosted by the custodial service. The relay may then enforce authentication to prevent metadata leaks. Not depending on a 3rd party relay would also improve reliability in this case. + +## Appendix + +### Example NIP-47 info event + +```jsonc +{ + "id": "df467db0a9f9ec77ffe6f561811714ccaa2e26051c20f58f33c3d66d6c2b4d1c", + "pubkey": "c04ccd5c82fc1ea3499b9c6a5c0a7ab627fbe00a0116110d4c750faeaecba1e2", + "created_at": 1713883677, + "kind": 13194, + "tags": [ + [ + "notifications", + "payment_received payment_sent" + ] + ], + "content": "pay_invoice pay_keysend get_balance get_info make_invoice lookup_invoice list_transactions multi_pay_invoice multi_pay_keysend sign_message notifications", + "sig": "31f57b369459b5306a5353aa9e03be7fbde169bc881c3233625605dd12f53548179def16b9fe1137e6465d7e4d5bb27ce81fd6e75908c46b06269f4233c845d8" +} +``` \ No newline at end of file diff --git a/app/profile/settings/nwc/page.tsx b/app/profile/settings/nwc/page.tsx new file mode 100644 index 0000000..688fa4a --- /dev/null +++ b/app/profile/settings/nwc/page.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useParams } from 'next/navigation' +import { nip19 } from "nostr-tools"; +import { UpdateProfileForm } from "@/components/UpdateProfileForm"; +import { useEffect } from 'react'; +import { NostrWalletConnect } from '@/components/NostrWalletConnect'; + +export default function ProfileSettingsNWCPage() { + useEffect(() => { + document.title = `NWC Settings | LUMINA`; + }, []); + + let pubkey = null; + if (typeof window !== 'undefined') { + pubkey = window.localStorage.getItem('pubkey'); + } + // check if pubkey is not null and contains "npub" + // if so, then we need to convert it to a pubkey + if (pubkey && pubkey.includes("npub")) { + // convert npub to pubkey + pubkey = nip19.decode(pubkey.toString()).data.toString() + } + + return ( +
+
+
+

NWC Settings

+ {/*

+ Update your profile information that will be visible to others on the Nostr network +

*/} +
+
+
+ +
+
+
+
+ ); +} \ No newline at end of file diff --git a/components/NostrWalletConnect.tsx b/components/NostrWalletConnect.tsx new file mode 100644 index 0000000..e3da3a6 --- /dev/null +++ b/components/NostrWalletConnect.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { nwc } from "@getalby/sdk"; +import { Loader2, CheckCircle, XCircle, ExternalLink, Copy, Trash2 } from "lucide-react"; +import QRCode from "react-qr-code"; + +const NWC_STORAGE_KEY = "lumina-nwc-connection"; + +export function NostrWalletConnect() { + const [connectionUrl, setConnectionUrl] = useState(""); + const [nwcClient, setNwcClient] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); + const [walletInfo, setWalletInfo] = useState(null); + const [error, setError] = useState(null); + const [balance, setBalance] = useState(null); + + // Load saved connection from localStorage on component mount + useEffect(() => { + const savedConnection = localStorage.getItem(NWC_STORAGE_KEY); + if (savedConnection) { + setConnectionUrl(savedConnection); + connectWithSavedUrl(savedConnection); + } + }, []); + + // Connect with a saved connection URL + const connectWithSavedUrl = async (url: string) => { + if (!url) return; + + try { + setIsConnecting(true); + setError(null); + + const client = new nwc.NWCClient({ + nostrWalletConnectUrl: url, + }); + + // Test the connection by getting wallet info + const info = await client.getInfo(); + + setNwcClient(client); + setWalletInfo(info); + setIsConnected(true); + + // Get balance + try { + const balanceInfo = await client.getBalance(); + setBalance(balanceInfo.balance / 1000); // Convert to sats + } catch (e) { + console.error("Error fetching balance:", e); + } + + } catch (e) { + console.error("NWC connection error:", e); + setError("Failed to connect to wallet. Please check your connection URL."); + } finally { + setIsConnecting(false); + } + }; + + // Connect with a new NWC URL + const handleConnect = async () => { + if (!connectionUrl) { + setError("Please enter a valid NWC connection URL"); + return; + } + + try { + await connectWithSavedUrl(connectionUrl); + + // If we get here without an error being thrown, connection was successful + // Save to localStorage + localStorage.setItem(NWC_STORAGE_KEY, connectionUrl); + } catch (error) { + // Error will be handled in connectWithSavedUrl + console.error("Connection failed:", error); + } + }; + + // Handle authorizing with a new wallet + const handleAuthorize = async () => { + try { + setIsConnecting(true); + setError(null); + + const client = await nwc.NWCClient.fromAuthorizationUrl( + "https://my.albyhub.com/apps/new", + { + name: "Lumina", + } + ); + + // Get the connection URL for storage + const newConnectionUrl = client.getNostrWalletConnectUrl(); + setConnectionUrl(newConnectionUrl); + + // Test the connection by getting wallet info + const info = await client.getInfo(); + + setNwcClient(client); + setWalletInfo(info); + setIsConnected(true); + + // Save to localStorage + localStorage.setItem(NWC_STORAGE_KEY, newConnectionUrl); + + // Get balance + try { + const balanceInfo = await client.getBalance(); + setBalance(balanceInfo.balance / 1000); + } catch (e) { + console.error("Error fetching balance:", e); + } + + } catch (e) { + console.error("NWC authorization error:", e); + setError("Failed to authorize with wallet."); + } finally { + setIsConnecting(false); + } + }; + + // Disconnect wallet + const handleDisconnect = () => { + localStorage.removeItem(NWC_STORAGE_KEY); + setConnectionUrl(""); + setNwcClient(null); + setWalletInfo(null); + setIsConnected(false); + setBalance(null); + }; + + // Copy connection URL to clipboard + const copyConnectionUrl = () => { + navigator.clipboard.writeText(connectionUrl); + // You could add a toast notification here + }; + + return ( +
+ {!isConnected ? ( + + + Connect Lightning Wallet + + Connect your Lightning wallet using Nostr Wallet Connect (NWC) + + + +
+ + setConnectionUrl(e.target.value)} + className="font-mono text-sm" + /> + {error &&

{error}

} +
+
+ + + + +
+ ) : ( + + + + Connected Wallet + + + + Your Lightning wallet is connected via Nostr Wallet Connect + + + + {walletInfo && ( +
+
+

Wallet Info

+

+ {walletInfo.alias || "Unknown Wallet"} +

+
+ +
+

Balance

+

+ {balance !== null ? `${balance} sats` : "Not available"} +

+
+ +
+

Connection URL

+
+

+ {connectionUrl.substring(0, 20)}... +

+ +
+
+
+ )} +
+ + + +
+ )} + + {isConnected && ( + + + Connection QR Code + + Scan this QR code with a compatible wallet to connect + + + +
+ +
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/components/ZapButton.tsx b/components/ZapButton.tsx index 590bbc7..5cf58d2 100644 --- a/components/ZapButton.tsx +++ b/components/ZapButton.tsx @@ -19,8 +19,13 @@ import { useNostr, useNostrEvents, useProfile } from "nostr-react"; import { useEffect, useState, useRef } from "react"; import QRCode from "react-qr-code"; import Link from "next/link"; -import { Alert } from "./ui/alert"; +import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; import { signEvent } from "@/utils/utils"; +import { nwc } from "@getalby/sdk"; +import { Sparkles, Zap } from "lucide-react"; + +// NWC connection storage key (same as used in NostrWalletConnect.tsx) +const NWC_STORAGE_KEY = "lumina-nwc-connection"; export default function ZapButton({ event }: { event: any }) { @@ -40,12 +45,18 @@ export default function ZapButton({ event }: { event: any }) { const [lnurlPayInfo, setLnurlPayInfo] = useState(null); const [invoice, setInvoice] = useState(""); - const [customAmount, setCustomAmount] = useState("1000"); + const [customAmount, setCustomAmount] = useState("21"); const [isProcessing, setIsProcessing] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const [paymentComplete, setPaymentComplete] = useState(false); const { publish } = useNostr(); + // NWC state + const [useNwc, setUseNwc] = useState(false); + const [nwcClient, setNwcClient] = useState(null); + const [nwcPaymentStatus, setNwcPaymentStatus] = useState(""); + const [paymentPreimage, setPaymentPreimage] = useState(""); + // Store the initial count of zap receipts when an invoice is generated const invoiceEventsCountRef = useRef(0); @@ -63,11 +74,11 @@ export default function ZapButton({ event }: { event: any }) { if (invoice && events.length > invoiceEventsCountRef.current) { // Filter events to find new zap receipts related to current invoice const newEvents = events.slice(invoiceEventsCountRef.current); - + // Check if any new events contain the current invoice const relevantEvents = newEvents.filter(zapEvent => { // Look for bolt11 tag containing the invoice - return zapEvent.tags.some(tag => + return zapEvent.tags.some(tag => tag[0] === 'bolt11' && invoice.includes(tag[1].substring(0, 50)) ); }); @@ -78,6 +89,34 @@ export default function ZapButton({ event }: { event: any }) { } }, [events, invoice]); + // Initialize NWC client on first render + useEffect(() => { + initializeNwc(); + }, []); + + // Initialize NWC client from localStorage + const initializeNwc = async () => { + if (typeof window === 'undefined') return; + + const connectionUrl = localStorage.getItem(NWC_STORAGE_KEY); + if (!connectionUrl) return; + + try { + const client = new nwc.NWCClient({ + nostrWalletConnectUrl: connectionUrl, + }); + + // Test the connection by getting wallet info + await client.getInfo(); + + setNwcClient(client); + setUseNwc(true); + } catch (error) { + console.error("Failed to initialize NWC client:", error); + // Don't show error to user here as this is just an initialization check + } + }; + const { data: userData } = useProfile({ pubkey: event.pubkey, }); @@ -102,9 +141,9 @@ export default function ZapButton({ event }: { event: any }) { try { setIsProcessing(true); - + let lnurl; - + if (userData.lud06) { lnurl = userData.lud06; } else if (userData.lud16) { @@ -132,25 +171,25 @@ export default function ZapButton({ event }: { event: any }) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout - const response = await fetch(lnurl, { + const response = await fetch(lnurl, { signal: controller.signal, headers: { 'Accept': 'application/json', } }); - + clearTimeout(timeoutId); - + // Check content type to ensure it's JSON const contentType = response.headers.get('content-type'); if (!contentType || !contentType.includes('application/json')) { console.error("Invalid content type:", contentType); throw new Error("Lightning service returned invalid content type (expected JSON)"); } - + // Parse response as JSON const data = await response.json(); - + if (!response.ok) { throw new Error(`Error fetching LNURL info: ${data.reason || response.statusText || 'Unknown error'}`); } @@ -238,20 +277,20 @@ export default function ZapButton({ event }: { event: any }) { const signedZapRequest = await signEvent(loginType, zapRequestEvent); params.append('nostr', JSON.stringify(signedZapRequest)); - + if (userData?.lud06) { params.append('lnurl', userData.lud06); } - + let callbackUrl = `${lnurlPayInfo.callback}?${params.toString()}`; - + const invoiceResponse = await fetch(callbackUrl); const invoiceData = await invoiceResponse.json(); - + if (!invoiceResponse.ok || !invoiceData.pr) { throw new Error(`Failed to get invoice: ${invoiceData.reason || 'Unknown error'}`); } - + setInvoice(invoiceData.pr); } catch (error) { console.error("Error creating zap request:", error); @@ -261,11 +300,165 @@ export default function ZapButton({ event }: { event: any }) { } }; - const handleZap = async (amountSats: number) => { - if (!lnurlPayInfo) { - await fetchLnurlInfo(); + // Handle sending a zap using NWC + const handleNwcPayment = async (amountSats: number) => { + if (!nwcClient) { + setErrorMessage("NWC client not initialized"); + return; + } + + if (!userData?.lud16 && !userData?.lud06) { + setErrorMessage("This user doesn't have a Lightning address configured"); + return; + } + + setIsProcessing(true); + setNwcPaymentStatus("Preparing payment..."); + setPaymentComplete(false); + setErrorMessage(""); + + try { + let senderPubkey = ''; + if (typeof window !== 'undefined') { + senderPubkey = window.localStorage.getItem('pubkey') ?? ''; + } + + if (!senderPubkey) { + setErrorMessage("You need to be logged in to send zaps"); + return; + } + + // Create zap request event + let zapRequestEvent = { + kind: 9734, + content: "", + tags: [ + ["relays", ...connectedRelays.map((relay) => relay.url)], + ["amount", (amountSats * 1000).toString()], + ["p", event.pubkey], + ["e", event.id], + ], + created_at: Math.floor(Date.now() / 1000), + pubkey: senderPubkey, + id: "", + sig: "", + }; + + // Sign the zap request + setNwcPaymentStatus("Signing zap request..."); + const signedZapRequest = await signEvent(loginType, zapRequestEvent); + + // Get LNURL payment info for the Lightning address + let recipient = ''; + if (userData?.lud16) { + recipient = userData.lud16; + } else if (userData?.lud06) { + recipient = userData.lud06; + } + + if (!recipient) { + throw new Error("Could not determine recipient lightning address"); + } + + setNwcPaymentStatus("Getting invoice from recipient..."); + + // Get LNURL payment info and invoice + let lnurl; + let invoice; + + if (recipient.includes('@')) { + // Handle Lightning Address (lud16) + const [name, domain] = recipient.split('@'); + const url = `https://${domain}/.well-known/lnurlp/${name}`; + + // Get LNURL callback info + const lnurlResponse = await fetch(url); + const lnurlData = await lnurlResponse.json(); + + if (!lnurlData.callback || !lnurlData.allowsNostr) { + throw new Error("Lightning address doesn't support Nostr zaps"); + } + + // Get invoice with zap request + const params = new URLSearchParams(); + params.append('amount', (amountSats * 1000).toString()); + params.append('nostr', JSON.stringify(signedZapRequest)); + + const callbackUrl = `${lnurlData.callback}?${params.toString()}`; + const invoiceResponse = await fetch(callbackUrl); + const invoiceData = await invoiceResponse.json(); + + if (!invoiceResponse.ok || !invoiceData.pr) { + throw new Error(`Failed to get invoice: ${invoiceData.reason || 'Unknown error'}`); + } + + invoice = invoiceData.pr; + } else { + // Handle LNURL directly (lud06) + // Decode if needed + if (recipient.toLowerCase().startsWith('lnurl')) { + try { + recipient = bech32ToUrl(recipient); + } catch (e) { + throw new Error("Invalid LNURL format"); + } + } + + // Get LNURL callback info + const lnurlResponse = await fetch(recipient); + const lnurlData = await lnurlResponse.json(); + + if (!lnurlData.callback || !lnurlData.allowsNostr) { + throw new Error("LNURL doesn't support Nostr zaps"); + } + + // Get invoice with zap request + const params = new URLSearchParams(); + params.append('amount', (amountSats * 1000).toString()); + params.append('nostr', JSON.stringify(signedZapRequest)); + + const callbackUrl = `${lnurlData.callback}?${params.toString()}`; + const invoiceResponse = await fetch(callbackUrl); + const invoiceData = await invoiceResponse.json(); + + if (!invoiceResponse.ok || !invoiceData.pr) { + throw new Error(`Failed to get invoice: ${invoiceData.reason || 'Unknown error'}`); + } + + invoice = invoiceData.pr; + } + + // Now pay the invoice using NWC + setNwcPaymentStatus("Sending payment via NWC..."); + const paymentResponse = await nwcClient.payInvoice({ + invoice: invoice + }); + + if (paymentResponse.preimage) { + setPaymentPreimage(paymentResponse.preimage); + setPaymentComplete(true); + setNwcPaymentStatus("Payment sent successfully!"); + } else { + throw new Error("Payment failed - no preimage returned"); + } + } catch (error) { + console.error("NWC payment error:", error); + setNwcPaymentStatus(""); + setErrorMessage(error instanceof Error ? error.message : "Payment failed"); + } finally { + setIsProcessing(false); + } + }; + + const handleZap = async (amountSats: number) => { + if (useNwc && nwcClient) { + await handleNwcPayment(amountSats); + } else { + if (!lnurlPayInfo) { + await fetchLnurlInfo(); + } + createZapRequest(amountSats * 1000); } - createZapRequest(amountSats * 1000); }; const handleCustomZap = async () => { @@ -274,11 +467,15 @@ export default function ZapButton({ event }: { event: any }) { setErrorMessage("Please enter a valid amount"); return; } - - if (!lnurlPayInfo) { - await fetchLnurlInfo(); + + if (useNwc && nwcClient) { + await handleNwcPayment(amount); + } else { + if (!lnurlPayInfo) { + await fetchLnurlInfo(); + } + createZapRequest(amount * 1000); } - createZapRequest(amount * 1000); }; const bech32ToUrl = (lnurl: string): string => { @@ -291,13 +488,17 @@ export default function ZapButton({ event }: { event: any }) { }; const handleOpenDrawer = () => { - fetchLnurlInfo(); + if (!useNwc) { + fetchLnurlInfo(); + } }; const handleCloseDrawer = () => { setInvoice(""); setErrorMessage(""); setIsProcessing(false); + setPaymentComplete(false); + setNwcPaymentStatus(""); }; const checkPaymentStatus = async () => { @@ -308,7 +509,7 @@ export default function ZapButton({ event }: { event: any }) { '#e': [event.id], kinds: [9735], }; - + // Manually check relays for new zap events const zapPromises = connectedRelays.map(async (relay) => { return new Promise(async (resolve) => { @@ -316,17 +517,17 @@ export default function ZapButton({ event }: { event: any }) { try { const sub = relay.sub([eventFilter]); const events: any[] = []; - + sub.on('event', (event) => { // Check if this event contains the current invoice - const hasBolt11 = event.tags.some(tag => + const hasBolt11 = event.tags.some(tag => tag[0] === 'bolt11' && invoice.includes(tag[1].substring(0, 50)) ); if (hasBolt11) { events.push(event); } }); - + sub.on('eose', () => { clearTimeout(timeout); resolve(events); @@ -338,10 +539,10 @@ export default function ZapButton({ event }: { event: any }) { } }); }); - + const zapEventsArrays = await Promise.all(zapPromises); const newZapEvents = zapEventsArrays.flat(); - + if (newZapEvents.length > 0) { setPaymentComplete(true); } @@ -363,104 +564,209 @@ export default function ZapButton({ event }: { event: any }) { {errorMessage && ( - - {errorMessage} + + {/* */} + Error + {errorMessage} )} - {invoice ? ( -
- {paymentComplete ? ( -
- -

- Payment Complete! -

-
- ) : ( -
- - - -
+ {/* NWC Payment Mode */} + {useNwc && !invoice && ( +
+ {!paymentComplete && ( + + + NWC Enabled + Payments will be sent directly through your connected NWC wallet + )} - -

- {paymentComplete - ? "Your payment has been received and confirmed!" - : "Scan this QR code with a Lightning wallet to pay the invoice"} -

- -
- {invoice} -
- - {paymentComplete ? ( -
- - Zap sent successfully! -
- ) : ( - + {nwcPaymentStatus} + )} - - -
- ) : ( - <> -
- - -
- setCustomAmount(e.target.value)} - disabled={isProcessing} - /> -
-
+ )} -
- + {(!paymentComplete || !isProcessing) && !paymentComplete && ( +
+ + +
+ setCustomAmount(e.target.value)} + disabled={isProcessing} + /> + +
+
+ )} + + {!paymentComplete &&
} + {!paymentComplete && } +
+ )} + + {/* Original LNURL Payment Flow */} + {(!useNwc || invoice) && ( + <> + {invoice ? ( +
+ {paymentComplete ? ( +
+ +

+ Payment Complete! +

+
+ ) : ( +
+ + + +
+ )} + + {!paymentComplete && ( + <> +

+ Scan this QR code with a Lightning wallet to pay the invoice +

+
+ {invoice} +
+ + )} + + + {paymentComplete ? ( +
+ + Zap sent successfully! +
+ ) : ( + + )} + + +
+ ) : ( + <> +
+ + +
+ setCustomAmount(e.target.value)} + disabled={isProcessing} + /> + +
+
+ +
+ + + )} )}
diff --git a/components/headerComponents/AvatarDropdown.tsx b/components/headerComponents/AvatarDropdown.tsx index cad8cf8..c3edb9f 100644 --- a/components/headerComponents/AvatarDropdown.tsx +++ b/components/headerComponents/AvatarDropdown.tsx @@ -49,6 +49,11 @@ export function AvatarDropdown() { Settings + + + NWC + + { window.localStorage.clear(); window.location.href = "/"; diff --git a/package-lock.json b/package-lock.json index ef2fad2..ee9b69b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "lumina", "version": "0.1.17", "dependencies": { + "@getalby/sdk": "^5.0.0", "@hookform/resolvers": "^3.4.0", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-avatar": "^1.0.4", @@ -1766,6 +1767,36 @@ "version": "0.2.1", "license": "MIT" }, + "node_modules/@getalby/lightning-tools": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@getalby/lightning-tools/-/lightning-tools-5.1.2.tgz", + "integrity": "sha512-BwGm8eGbPh59BVa1gI5yJMantBl/Fdps6X4p1ZACnmxz9vDINX8/3aFoOnDlF7yyA2boXWCsReVQSr26Q2yjiQ==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "lightning", + "url": "lightning:hello@getalby.com" + } + }, + "node_modules/@getalby/sdk": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@getalby/sdk/-/sdk-5.0.0.tgz", + "integrity": "sha512-PyXgLZN3p+xZIPqyNyTwE0WxTCZI1YJK8TV/amOjdNkecQCR3LnVsusdtZfoszyS+pVo/HaSwF5zCQWUmz8I+Q==", + "license": "MIT", + "dependencies": { + "@getalby/lightning-tools": "^5.1.2", + "nostr-tools": "2.9.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "lightning", + "url": "lightning:hello@getalby.com" + } + }, "node_modules/@hookform/resolvers": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.4.0.tgz", @@ -8491,9 +8522,10 @@ } }, "node_modules/nostr-tools": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.4.0.tgz", - "integrity": "sha512-xQC7XdGeh0gLyprcKhvx5lwr7OQ+ZOiQ9C6GpzlVAj+EBv+AiN8kySb57t3uJoG1HK15oT9jf++MmQLwhp1xNQ==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.9.4.tgz", + "integrity": "sha512-Powumwkp+EWbdK1T8IsEX4daTLQhtWJvitfZ6OP2BdU1jJZvNlUp3SQB541UYw4uc9jgLbxZW6EZSdZoSfIygQ==", + "license": "Unlicense", "dependencies": { "@noble/ciphers": "^0.5.1", "@noble/curves": "1.2.0", diff --git a/package.json b/package.json index 8c24d88..a624608 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@getalby/sdk": "^5.0.0", "@hookform/resolvers": "^3.4.0", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-avatar": "^1.0.4",