Skip to content

Commit

Permalink
Implement new NIP47 spec (#6)
Browse files Browse the repository at this point in the history
* Implement new NIP47 spec

* Refactor defaults and make it extensible

* add websocket-polifyll as dev dependency for testing

* Add getInfo call

* Return NIP47 error code in sendPayment response

* No more 23196 error kind

* Catch JSON errors in the response

* Default to adding the secret to the URL
  • Loading branch information
bumi authored Apr 24, 2023
1 parent a77123d commit eae7e39
Show file tree
Hide file tree
Showing 4 changed files with 233 additions and 115 deletions.
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,15 @@
"dependencies": {
"cross-fetch": "^3.1.5",
"crypto-js": "^4.1.1",
"nostr-tools": "^1.7.5"
"nostr-tools": "^1.10.0"
},
"devDependencies": {
"@types/crypto-js": "^4.1.1",
"@types/node": "^18.11.0",
"express": "^4.18.2",
"microbundle": "^0.15.1",
"typescript": "^4.8.4"
"typescript": "^4.8.4",
"websocket-polyfill": "^0.0.3"
},
"engines": {
"node": ">=14"
Expand Down
6 changes: 6 additions & 0 deletions repl.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import * as repl from 'node:repl';
import { auth, Client, webln } from "./dist/index.module.js";
import 'websocket-polyfill';
try {
globalThis.crypto = await import('node:crypto');
} catch (err) {
console.error('crypto not found!');
}

const authClient = new auth.OAuth2User({
client_id: process.env.CLIENT_ID,
Expand Down
157 changes: 87 additions & 70 deletions src/webln/NostrWeblnProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,43 +11,31 @@ import {
UnsignedEvent
} from 'nostr-tools';

const DEFAULT_OPTIONS = {
relayUrl: "wss://relay.getalby.com/v1",
walletPubkey: '69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9' // Alby
};

const NWC_URLS = {
alby: "https://nwc.getalby.com/apps/new"
};

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;
const NWCs: Record<string,NostrWebLNOptions> = {
alby: {
connectUrl: "https://nwc.getalby.com/apps/new",
relayUrl: "wss://relay.getalby.com/v1",
walletPubkey: '69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9'
}
};

interface NostrWebLNOptions {
connectUrl?: string; // the URL to the NWC interface for the user to confirm the session
relayUrl: string;
walletPubkey: string;
secret?: string;
};


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

static parseWalletConnectUrl(walletConnectUrl: string) {
walletConnectUrl = walletConnectUrl.replace('nostrwalletconnect://', 'http://'); // makes it possible to parse with URL in the different environments (browser/node/...)
walletConnectUrl = walletConnectUrl.replace('nostr+walletconnect://', 'http://'); // makes it possible to parse with URL in the different environments (browser/node/...)
const url = new URL(walletConnectUrl);
const options = {} as NostrWebLNOptions;
options.walletPubkey = url.host;
Expand All @@ -68,20 +56,26 @@ export class NostrWebLNProvider {
return new NostrWebLNProvider(options);
}

constructor(options?: { relayUrl?: string, secret?: string, walletPubkey?: string, nostrWalletConnectUrl?: string }) {
constructor(options?: { providerName?: string, connectUrl?: string, relayUrl?: string, secret?: string, walletPubkey?: string, nostrWalletConnectUrl?: string }) {
if (options && options.nostrWalletConnectUrl) {
options = {
...NostrWebLNProvider.parseWalletConnectUrl(options.nostrWalletConnectUrl), ...options
};
}
const _options = { ...DEFAULT_OPTIONS, ...(options || {}) } as NostrWebLNOptions;
this.relayUrl = _options.relayUrl;
const providerOptions = NWCs[options?.providerName || 'alby'] as NostrWebLNOptions;
this.options = { ...providerOptions, ...(options || {}) } as NostrWebLNOptions;
this.relayUrl = this.options.relayUrl;
this.relay = relayInit(this.relayUrl);
if (_options.secret) {
this.secret = (_options.secret.toLowerCase().startsWith('nsec') ? nip19.decode(_options.secret).data : _options.secret) as string;
if (this.options.secret) {
this.secret = (this.options.secret.toLowerCase().startsWith('nsec') ? nip19.decode(this.options.secret).data : this.options.secret) as string;
}
this.walletPubkey = (_options.walletPubkey.toLowerCase().startsWith('npub') ? nip19.decode(_options.walletPubkey).data : _options.walletPubkey) as string;
this.walletPubkey = (this.options.walletPubkey.toLowerCase().startsWith('npub') ? nip19.decode(this.options.walletPubkey).data : this.options.walletPubkey) as string;
this.subscribers = {};

// @ts-ignore
if(globalThis.WebSocket === undefined) {
console.error("WebSocket is undefined. Make sure to `import websocket-polyfill` for nodejs environments");
}
}

on(name: string, callback: () => void) {
Expand All @@ -95,8 +89,8 @@ export class NostrWebLNProvider {
}
}

getNostrWalletConnectUrl(includeSecret = false) {
let url = `nostrwalletconnect://${this.walletPubkey}?relay=${this.relayUrl}&pubkey=${this.publicKey}`;
getNostrWalletConnectUrl(includeSecret = true) {
let url = `nostr+walletconnect://${this.walletPubkey}?relay=${this.relayUrl}&pubkey=${this.publicKey}`;
if (includeSecret) {
url = `${url}&secret=${this.secret}`;
}
Expand Down Expand Up @@ -141,56 +135,59 @@ export class NostrWebLNProvider {
}

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

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

// WebLN compatible response
// TODO: use NIP-47 get_info call
async getInfo() {
return {
methods: ["getInfo", "sendPayment"],
node: {},
supports: ["lightning"],
version: "NWC"
}
}

sendPayment(invoice: string) {
this.checkConnected();

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

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

// subscribe to NIP_47_SUCCESS_RESPONSE_KIND and NIP_47_ERROR_RESPONSE_KIND
// that reference the request event (NIP_47_REQUEST_KIND)
let sub = this.relay.sub([
{
kinds: [23195, 23196],
kinds: [23195],
authors: [this.walletPubkey],
"#e": [event.id],
}
Expand All @@ -209,27 +206,34 @@ export class NostrWebLNProvider {
clearTimeout(replyTimeoutCheck);
sub.unsub();
const decryptedContent = await this.decrypt(this.walletPubkey, event.content);
let response;
try {
response = JSON.parse(decryptedContent);
} catch(e) {
reject({ error: "invalid response", code: "INTERNAL" });
return;
}
// @ts-ignore // event is still unknown in nostr-tools
if (event.kind == 23195) {
resolve({ preimage: decryptedContent });
if (event.kind == 23195 && response.result?.preimage) {
resolve({ preimage: response.result.preimage });
this.notify('sendPayment', event.content);
} else {
reject({ error: decryptedContent });
reject({ error: response.error?.message, code: response.error?.code });
}
});

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

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

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

pub.on('ok', () => {
Expand All @@ -239,20 +243,33 @@ export class NostrWebLNProvider {
});
}

initNWC(providerNameOrUrl: string, options: { name: string, returnTo?: string }) {
getConnectUrl(options: { name?: string, returnTo?: string }) {
if (!this.options.connectUrl) {
throw new Error("Missing connectUrl option");
}
const url = new URL(this.options.connectUrl);
if (options?.name) {
url.searchParams.set('c', options?.name);
}
url.searchParams.set('pubkey', this.publicKey);
if (options?.returnTo) {
url.searchParams.set('returnTo', options.returnTo);
}
return url;
}

initNWC(options: { name?: string, returnTo?: string } = {}) {
// here we assume an browser context and window/document is available
// we set the location.host as a default name if none is given
if (!options.name) {
options.name = document.location.host;
}
const url = this.getConnectUrl(options);
const height = 600;
const width = 400;
const top = window.outerHeight / 2 + window.screenY - height / 2;
const left = window.outerWidth / 2 + window.screenX - width / 2;

const urlStr = NWC_URLS[providerNameOrUrl as keyof typeof NWC_URLS] || providerNameOrUrl;
const url = new URL(urlStr);
url.searchParams.set('c', options.name);
url.searchParams.set('pubkey', this.publicKey);
url.searchParams.set('url', document.location.origin);
if (options.returnTo) {
url.searchParams.set('returnTo', options.returnTo);
}

return new Promise((resolve, reject) => {
const popup = window.open(
url.toString(),
Expand Down
Loading

0 comments on commit eae7e39

Please sign in to comment.