Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nostr wallet connect #2

Merged
merged 6 commits into from Mar 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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';