Skip to content

Commit

Permalink
feat: add sendMultiPayment function and example
Browse files Browse the repository at this point in the history
  • Loading branch information
rolznz committed Jan 22, 2024
1 parent 9412d1a commit 50e59f8
Show file tree
Hide file tree
Showing 4 changed files with 244 additions and 5 deletions.
45 changes: 45 additions & 0 deletions examples/nwc/send-multi-payment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as crypto from "node:crypto"; // required in node.js
global.crypto = crypto; // required in node.js
import "websocket-polyfill"; // required in node.js

import { LightningAddress } from "@getalby/lightning-tools";

import * as readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";

import { webln as providers } from "../../dist/index.module.js";

const rl = readline.createInterface({ input, output });

const ln = new LightningAddress(process.env.LN_ADDRESS || "[email protected]");
// fetch the LNURL data
await ln.fetch();

// generate 2 invoices to pay
const invoices = (
await Promise.all(
[1, 2].map((v) =>
ln.requestInvoice({
satoshi: 1,
comment: `Multi-pay invoice #${v}`,
}),
),
)
).map((invoice) => invoice.paymentRequest);

console.info("Generated two invoices", invoices);

const nwcUrl =
process.env.NWC_URL ||
(await rl.question("Nostr Wallet Connect URL (nostr+walletconnect://...): "));
rl.close();

const webln = new providers.NostrWebLNProvider({
nostrWalletConnectUrl: nwcUrl,
});
await webln.enable();
const response = await webln.sendMultiPayment(invoices);

console.info(response);

webln.close();
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@
"prepare": "husky install"
},
"dependencies": {
"nostr-tools": "^1.17.0",
"events": "^3.3.0"
"@getalby/lightning-tools": "^5.0.1",
"events": "^3.3.0",
"nostr-tools": "^1.17.0"
},
"devDependencies": {
"@commitlint/cli": "^17.7.1",
Expand Down
194 changes: 191 additions & 3 deletions src/webln/NostrWeblnProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
Event,
UnsignedEvent,
finishEvent,
Kind,
} from "nostr-tools";
import {
GetBalanceResponse,
Expand All @@ -26,6 +25,7 @@ import {
} from "@webbtc/webln-types";
import { GetInfoResponse } from "@webbtc/webln-types";
import { NWCAuthorizationUrlOptions } from "../types";
import { Invoice } from "@getalby/lightning-tools";

const NWCs: Record<string, NostrWebLNOptions> = {
alby: {
Expand All @@ -47,6 +47,12 @@ export type ListTransactionsResponse = {
// TODO: consider moving to webln-types package
export type ListTransactionsArgs = Nip47ListTransactionsArgs;

// TODO: consider moving to webln-types package
export type SendMultiPaymentResponse = {
payments: ({ paymentRequest: string } & SendPaymentResponse)[];
errors: { paymentRequest: string; message: string }[];
};

interface Nip47ListTransactionsArgs {
from?: number;
until?: number;
Expand Down Expand Up @@ -110,6 +116,9 @@ const nip47ToWeblnRequestMap = {
lookup_invoice: "lookupInvoice",
list_transactions: "listTransactions",
};
const nip47ToWeblnMultiRequestMap = {
multi_pay_invoice: "sendMultiPayment",
};

export class NostrWebLNProvider implements WebLNProvider, Nip07Provider {
relay: Relay;
Expand Down Expand Up @@ -338,6 +347,51 @@ export class NostrWebLNProvider implements WebLNProvider, Nip07Provider {
);
}

async sendMultiPayment(
paymentRequests: string[],
): Promise<SendMultiPaymentResponse> {
await this.checkConnected();

// get payment hashes of the payment requests
const paymentHashToPaymentRequestMap: Record<string, string> = {};
for (const paymentRequest of paymentRequests) {
paymentHashToPaymentRequestMap[
new Invoice({ pr: paymentRequest }).paymentHash
] = paymentRequest;
}

const results = await this.executeMultiNip47Request<
{ preimage: string; paymentRequest: string },
Nip47PayResponse
>(
"multi_pay_invoice",
{
invoices: paymentRequests.map((paymentRequest) => ({
invoice: paymentRequest,
})),
},
paymentRequests.length,
(result) => !!result.preimage,
(result) => {
const paymentRequest = paymentHashToPaymentRequestMap[result.dTag];
if (!paymentRequest) {
throw new Error(
"Could not find paymentRequest matching response d tag",
);
}
return {
paymentRequest,
preimage: result.preimage,
};
},
);

return {
payments: results,
errors: [],
};
}

async keysend(args: KeysendArgs) {
await this.checkConnected();

Expand Down Expand Up @@ -559,7 +613,7 @@ export class NostrWebLNProvider implements WebLNProvider, Nip07Provider {
JSON.stringify(command),
);
const unsignedEvent: UnsignedEvent = {
kind: 23194 as Kind,
kind: 23194,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", this.walletPubkey]],
content: encryptedCommand,
Expand Down Expand Up @@ -604,7 +658,6 @@ export class NostrWebLNProvider implements WebLNProvider, Nip07Provider {
reject({ error: "invalid response", code: "INTERNAL" });
return;
}
// @ts-ignore // event is still unknown in nostr-tools
if (event.kind == 23195 && response.result) {
// console.info("NIP-47 result", response.result);
if (resultValidator(response.result)) {
Expand Down Expand Up @@ -644,6 +697,141 @@ export class NostrWebLNProvider implements WebLNProvider, Nip07Provider {
})();
});
}

// TODO: this method currently fails if any payment fails.
// this could be improved in the future.
// TODO: reduce duplication between executeNip47Request and executeMultiNip47Request
private executeMultiNip47Request<T, R>(
nip47Method: keyof typeof nip47ToWeblnMultiRequestMap,
params: unknown,
numPayments: number,
resultValidator: (result: R) => boolean,
resultMapper: (result: R & { dTag: string }) => T,
) {
const weblnMethod = nip47ToWeblnMultiRequestMap[nip47Method];
let numPaymentsReceived = 0;
const results: (R & { dTag: string })[] = [];
return new Promise<T[]>((resolve, reject) => {
(async () => {
const command = {
method: nip47Method,
params,
};
const encryptedCommand = await this.encrypt(
this.walletPubkey,
JSON.stringify(command),
);
const unsignedEvent: UnsignedEvent = {
kind: 23194,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", this.walletPubkey]],
content: encryptedCommand,
pubkey: this.publicKey,
};

const event = await this.signEvent(unsignedEvent);
// subscribe to NIP_47_SUCCESS_RESPONSE_KIND and NIP_47_ERROR_RESPONSE_KIND
// that reference the request event (NIP_47_REQUEST_KIND)
const sub = this.relay.sub([
{
kinds: [23195],
authors: [this.walletPubkey],
"#e": [event.id],
},
]);

function replyTimeout() {
sub.unsub();
//console.error(`Reply timeout: event ${event.id} `);
reject({
error: `reply timeout: event ${event.id}`,
code: "INTERNAL",
});
}

const replyTimeoutCheck = setTimeout(replyTimeout, 60000);

sub.on("event", async (event) => {
++numPaymentsReceived;
// console.log(`Received reply event: `, event);

const decryptedContent = await this.decrypt(
this.walletPubkey,
event.content,
);
// console.log(`Decrypted content: `, decryptedContent);
let response;
try {
response = JSON.parse(decryptedContent);
} catch (e) {
console.error(e);
clearTimeout(replyTimeoutCheck);
sub.unsub();
reject({ error: "invalid response", code: "INTERNAL" });
return;
}
if (event.kind == 23195 && response.result) {
// console.info("NIP-47 result", response.result);
try {
if (!resultValidator(response.result)) {
throw new Error(
"Response from NWC failed validation: " +
JSON.stringify(response.result),
);
}
const dTag = event.tags.find((tag) => tag[0] === "d")?.[1];
if (!dTag) {
throw new Error("No d tag found in response event");
}
// console.info("dTag", dTag);
results.push({
...response.result,
dTag,
});
if (numPaymentsReceived === numPayments) {
clearTimeout(replyTimeoutCheck);
sub.unsub();
//console.log("Received results", results);
resolve(results.map(resultMapper));
this.notify(weblnMethod, response.result);
}
} catch (error) {
console.error(error);
clearTimeout(replyTimeoutCheck);
sub.unsub();
reject({
error: (error as Error).message,
code: "INTERNAL",
});
}
} else {
clearTimeout(replyTimeoutCheck);
sub.unsub();
reject({
error: response.error?.message,
code: response.error?.code,
});
}
});

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

try {
await this.relay.publish(event);
clearTimeout(publishTimeoutCheck);
//console.debug(`Event ${event.id} for ${invoice} published`);
} catch (error) {
//console.error(`Failed to publish to ${this.relay.url}`, error);
clearTimeout(publishTimeoutCheck);
reject({ error: `Failed to publish request: ${error}` });
}
})();
});
}
}

function mapNip47TransactionToTransaction(
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1470,6 +1470,11 @@
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.47.0.tgz#5478fdf443ff8158f9de171c704ae45308696c7d"
integrity sha512-P6omY1zv5MItm93kLM8s2vr1HICJH8v0dvddDhysbIuZ+vcjOHg5Zbkf1mTkcmi2JA9oBG2anOkRnW8WJTS8Og==

"@getalby/lightning-tools@^5.0.1":
version "5.0.1"
resolved "https://registry.yarnpkg.com/@getalby/lightning-tools/-/lightning-tools-5.0.1.tgz#08a974bcdf3d98a86ff9909df838360ee67174c8"
integrity sha512-xoBfBYMQrJqwryU9fAYGIW6dzWRpdsAw8rroqTROba2bHdYT0ZvGnt4tjqXUhRswopR2X+wp1QeeWHZNL9A0Kg==

"@humanwhocodes/config-array@^0.11.10":
version "0.11.10"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.10.tgz#5a3ffe32cc9306365fb3fd572596cd602d5e12d2"
Expand Down

0 comments on commit 50e59f8

Please sign in to comment.