Skip to content

Commit

Permalink
Merge pull request #2 from getAlby/nostr-wallet-connect
Browse files Browse the repository at this point in the history
Nostr wallet connect
  • Loading branch information
bumi committed Mar 27, 2023
2 parents 3706887 + 4ab62fc commit 85d4ffb
Show file tree
Hide file tree
Showing 11 changed files with 366 additions and 18 deletions.
2 changes: 1 addition & 1 deletion .node-version
@@ -1 +1 @@
18.1.0
18.12.1
43 changes: 40 additions & 3 deletions README.md
Expand Up @@ -2,16 +2,53 @@

## Introduction

This JavaScript SDK for the Alby OAuth2 Wallet API.

This JavaScript SDK for the Alby OAuth2 Wallet API and the Nostr Wallet Connect API.

## Installing

```
npm install alby-js-sdk
```

## API Documentation
## Nostr Wallet Connect Documentation

Nostr Wallet Connect 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.

The `NostrWebLNProvider` exposes the [WebLN](webln.guide/) sendPayment interface to execute lightning payments through Nostr Wallet Connect.

(note: in the future more WebLN functions will be added to Nostr Wallet Connect)


### NostrWebLNProvider Options

* `nostrWalletConnectUrl`: the full Nostr Wallet Connect URL as defined by the [spec](https://github.com/getAlby/nips/blob/master/47.md)
* `relayUrl`: the URL of the Nostr relay to be used (e.g. wss://nostr-relay.getalby.com)
* `walletPubkey`: the pubkey of the Nostr Wallet Connect app
* `privateKey`: the private key to sign the message (if not available window.nostr will be used)

### Example

#### Defaults
```js
import { NostrWebLNProvider } from 'alby-js-sdk';

const webln = new NostrWebLNProvider(); // use defaults (will use window.nostr to sign the request)
const response = webln.sendPayment(invoice);
console.log(response.preimage);
```

#### Use a custom, user provided Nostr Wallet Connect URL
```js
import { NostrWebLNProvider } from 'alby-js-sdk';

const webln = new NostrWebLNProvider({ nostrWalletConnectUrl: 'nostrwalletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=wss://nostr.bitcoiner.social&secret=c60320b3ecb6c15557510d1518ef41194e9f9337c82621ddef3f979f668bfebd'); // use defaults
const response = webln.sendPayment(invoice);
console.log(response.preimage);
```
## OAuth API Documentation
Please have a look a the Alby OAuth2 Wallet API:
Expand Down
3 changes: 2 additions & 1 deletion package.json
Expand Up @@ -25,7 +25,8 @@
},
"dependencies": {
"cross-fetch": "^3.1.5",
"crypto-js": "^4.1.1"
"crypto-js": "^4.1.1",
"nostr-tools": "^1.7.5"
},
"devDependencies": {
"@types/crypto-js": "^4.1.1",
Expand Down
3 changes: 2 additions & 1 deletion repl.js
@@ -1,5 +1,5 @@
import * as repl from 'node:repl';
import { auth, Client } from "./dist/index.module.js";
import { auth, Client, webln } from "./dist/index.module.js";

const authClient = new auth.OAuth2User({
client_id: process.env.CLIENT_ID,
Expand All @@ -21,5 +21,6 @@ console.log('use `authClient` and `alby` (the client)');


const r = repl.start('> ');
r.context.webln = webln;
r.context.authClient = authClient;
r.context.alby = new Client(authClient);
4 changes: 2 additions & 2 deletions src/index.ts
@@ -1,4 +1,4 @@
export * as auth from "./auth";
export * as types from './types'
export { Client } from "./client";
export { WebLNProvider } from "./WeblnProvider";
export * as webln from "./webln";
export { Client } from "./client";
45 changes: 45 additions & 0 deletions src/webln/AlbyOauthCallback.jsx
@@ -0,0 +1,45 @@
import { useEffect, useState } from "react";

const AlbyOauthCallback = () => {
const [error, setError] = useState(null);

useEffect(() => {
if (!window.opener) {
alert("Something went wrong. Opener not available. Please contact support@getalby.com");
return;
}
const params = new URLSearchParams(window.location.search)
const code = params.get("code");
const error = params.get("error");

if (!code) {
setError("declined");
}
if (error) {
setError(error);
alert(error);
return;
}

window.opener.postMessage({
type: 'alby:oauth:success',
payload: { code },
});
console.log("auth message published");

}, []);

return (
<div>
{error && (
<p>Authorization failed: {error}</p>
)}
{!error && (
<p>Connected. you can close this window.</p>
)}
</div>
)

}

export default AlbyOauthCallback;
190 changes: 190 additions & 0 deletions src/webln/NostrWeblnProvider.ts
@@ -0,0 +1,190 @@
import {
nip04,
relayInit,
signEvent,
getEventHash,
getPublicKey,
nip19,
Relay,
Event,
UnsignedEvent
} from 'nostr-tools';

const DEFAULT_OPTIONS = {
relayUrl: 'wss://relay.damus.io',
walletPubkey: '69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9' // Alby
};

interface Nostr {
signEvent: (event: UnsignedEvent) => Promise<Event>;
nip04: {
decrypt: (pubkey: string, content: string) => Promise<string>;
encrypt: (pubkey: string, content: string) => Promise<string>;
};
}
declare global {
var nostr: Nostr | undefined;
}

interface NostrWebLNOptions {
relayUrl: string;
walletPubkey: string;
privateKey?: string;
}


export class NostrWebLNProvider {
relay: Relay;
relayUrl: string;
privateKey: string | undefined;
walletPubkey: string;
subscribers: Record<string, (payload: any) => void>;
connected: boolean;

static parseWalletConnectUrl(walletConnectUrl: string) {
const url = new URL(walletConnectUrl);
const options = {} as NostrWebLNOptions;
options.walletPubkey = url.pathname.replace('//', '');
const privateKey = url.searchParams.get('secret');
const relayUrl = url.searchParams.get('relay');
if (privateKey) {
options.privateKey = privateKey;
}
if (relayUrl) {
options.relayUrl = relayUrl;
}
return options;
}
constructor(options: { relayUrl?: string, privateKey?: string, walletPubkey?: string, nostrWalletConnectUrl?: string }) {
if (options.nostrWalletConnectUrl) {
options = {
...NostrWebLNProvider.parseWalletConnectUrl(options.nostrWalletConnectUrl), ...options
};
}
const _options = { ...DEFAULT_OPTIONS, ...options } as NostrWebLNOptions;
this.relayUrl = _options.relayUrl;
this.relay = relayInit(this.relayUrl);
if (_options.privateKey) {
this.privateKey = (_options.privateKey.toLowerCase().startsWith('nsec') ? nip19.decode(_options.privateKey).data : _options.privateKey) as string;
}
this.walletPubkey = (_options.walletPubkey.toLowerCase().startsWith('npub') ? nip19.decode(_options.walletPubkey).data : _options.walletPubkey) as string;
this.subscribers = {};
this.connected = false;
}

on(name: string, callback: () => void) {
this.subscribers[name] = callback;
}

notify(name: string, payload?: any) {
const callback = this.subscribers[name];
if (callback) {
callback(payload);
}
}

async enable() {
if (this.connected) {
return Promise.resolve();
}
this.relay.on('connect', () => {
//console.debug(`connected to ${this.relay.url}`);
this.connected = true;
})
await this.relay.connect();
}

async encrypt(pubkey: string, content: string) {
let encrypted;
if (globalThis.nostr && !this.privateKey) {
encrypted = await globalThis.nostr.nip04.encrypt(pubkey, content);
} else if (this.privateKey) {
encrypted = await nip04.encrypt(this.privateKey, pubkey, content);
} else {
throw new Error("Missing private key");
}
return encrypted;
}

async decrypt(pubkey: string, content: string) {
let decrypted;
if (globalThis.nostr && !this.privateKey) {
decrypted = await globalThis.nostr.nip04.decrypt(pubkey, content);
} else if (this.privateKey) {
decrypted = await nip04.decrypt(this.privateKey, pubkey, content);
} else {
throw new Error("Missing private key");
}
return decrypted;
}

sendPayment(invoice: string) {
return new Promise(async (resolve, reject) => {
const encryptedInvoice = await this.encrypt(this.walletPubkey, invoice);
let event: any = {
kind: 23194,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', this.walletPubkey]],
content: encryptedInvoice,
};

if (globalThis.nostr && !this.privateKey) {
event = await globalThis.nostr.signEvent(event);
} else if (this.privateKey) {
event.pubkey = getPublicKey(this.privateKey)
event.id = getEventHash(event)
event.sig = signEvent(event, this.privateKey)
} else {
throw new Error("Missing private key");
}

let pub = this.relay.publish(event);

function publishTimeout() {
//console.error(`Publish timeout: event ${event.id}`);
reject('Publish timeout');
}
let publishTimeoutCheck = setTimeout(publishTimeout, 2000);

// @ts-ignore
pub.on('failed', (reason) => {
//console.debug(`failed to publish to ${this.relay.url}: ${reason}`)
clearTimeout(publishTimeoutCheck)
reject(`Failed to publish request: ${reason}`);
});

pub.on('ok', () => {
//console.debug(`Event ${event.id} for ${invoice} published`);
clearTimeout(publishTimeoutCheck);

function replyTimeout() {
//console.error(`Reply timeout: event ${event.id} `);
reject('reply timeout');
}
let replyTimeoutCheck = setTimeout(replyTimeout, 60000);

if (!this.relay) { return; } // mainly for TS
let sub = this.relay.sub([
{
kinds: [23195, 23196],
authors: [this.walletPubkey],
"#e": [event.id],
}
]);
sub.on('event', async (event) => {
//console.log(`Received reply event: `, event);
clearTimeout(replyTimeoutCheck);
sub.unsub();
const decryptedContent = await this.decrypt(this.walletPubkey, event.content);
// @ts-ignore // event is still unknown in nostr-tools
if (event.kind == 23195) {
resolve({ preimage: decryptedContent });
this.notify('sendPayment', event.content);
} else {
reject({ error: decryptedContent });
}
});
});
});
}
}
14 changes: 7 additions & 7 deletions src/WeblnProvider.ts → src/webln/OauthWeblnProvider.ts
@@ -1,8 +1,8 @@
import { Client } from './client';
import { Client } from '../client';
import {
OAuthClient,
KeysendRequestParams,
} from "./types";
} from "../types";

interface RequestInvoiceArgs {
amount: string | number;
Expand All @@ -11,7 +11,7 @@ interface RequestInvoiceArgs {

const isBrowser = () => typeof window !== "undefined" && typeof window.document !== "undefined";

export class WebLNProvider {
export class OauthWeblnProvider {
client: Client;
auth: OAuthClient;
oauth: boolean;
Expand Down Expand Up @@ -66,7 +66,7 @@ export class WebLNProvider {
return {
preimage: result.payment_preimage
}
} catch(error) {
} catch (error) {
let message = 'Unknown Error'
if (error instanceof Error) message = error.message
throw new Error(message);
Expand All @@ -87,7 +87,7 @@ export class WebLNProvider {
return {
preimage: result.payment_preimage
}
} catch(error) {
} catch (error) {
let message = 'Unknown Error'
if (error instanceof Error) message = error.message
throw new Error(message);
Expand All @@ -114,7 +114,7 @@ export class WebLNProvider {
return {
paymentRequest: result.payment_request
}
} catch(error) {
} catch (error) {
let message = 'Unknown Error'
if (error instanceof Error) message = error.message
throw new Error(message);
Expand Down Expand Up @@ -151,7 +151,7 @@ export class WebLNProvider {
}
this.notify('enable');
resolve({ enabled: true });
} catch(e) {
} catch (e) {
console.error(e);
reject({ enabled: false });
}
Expand Down
2 changes: 2 additions & 0 deletions src/webln/index.ts
@@ -0,0 +1,2 @@
export * from './NostrWeblnProvider';
export * from './OauthWeblnProvider';

0 comments on commit 85d4ffb

Please sign in to comment.