Skip to content

Commit 87b8e2d

Browse files
authored
Merge pull request #190 from getAlby/feat/send-multi-payment
feat: add sendMultiPayment function and example
2 parents 9412d1a + f60f4d9 commit 87b8e2d

File tree

4 files changed

+237
-5
lines changed

4 files changed

+237
-5
lines changed

examples/nwc/send-multi-payment.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import * as crypto from "node:crypto"; // required in node.js
2+
global.crypto = crypto; // required in node.js
3+
import "websocket-polyfill"; // required in node.js
4+
5+
import { LightningAddress } from "@getalby/lightning-tools";
6+
7+
import * as readline from "node:readline/promises";
8+
import { stdin as input, stdout as output } from "node:process";
9+
10+
import { webln as providers } from "../../dist/index.module.js";
11+
12+
const rl = readline.createInterface({ input, output });
13+
14+
const ln = new LightningAddress(process.env.LN_ADDRESS || "[email protected]");
15+
// fetch the LNURL data
16+
await ln.fetch();
17+
18+
// generate 2 invoices to pay
19+
const invoices = (
20+
await Promise.all(
21+
[1, 2].map((v) =>
22+
ln.requestInvoice({
23+
satoshi: 1,
24+
comment: `Multi-pay invoice #${v}`,
25+
}),
26+
),
27+
)
28+
).map((invoice) => invoice.paymentRequest);
29+
30+
console.info("Generated two invoices", invoices);
31+
32+
const nwcUrl =
33+
process.env.NWC_URL ||
34+
(await rl.question("Nostr Wallet Connect URL (nostr+walletconnect://...): "));
35+
rl.close();
36+
37+
const webln = new providers.NostrWebLNProvider({
38+
nostrWalletConnectUrl: nwcUrl,
39+
});
40+
await webln.enable();
41+
try {
42+
const response = await webln.sendMultiPayment(invoices);
43+
console.info(response);
44+
} catch (error) {
45+
console.error("sendMultiPayment failed", error);
46+
}
47+
48+
webln.close();

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,13 @@
3838
"prepare": "husky install"
3939
},
4040
"dependencies": {
41-
"nostr-tools": "^1.17.0",
42-
"events": "^3.3.0"
41+
"events": "^3.3.0",
42+
"nostr-tools": "^1.17.0"
4343
},
4444
"devDependencies": {
4545
"@commitlint/cli": "^17.7.1",
4646
"@commitlint/config-conventional": "^17.7.0",
47+
"@getalby/lightning-tools": "^5.0.1",
4748
"@types/jest": "^29.5.5",
4849
"@types/node": "^20.8.6",
4950
"@typescript-eslint/eslint-plugin": "^6.3.0",

src/webln/NostrWeblnProvider.ts

Lines changed: 181 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import {
99
Event,
1010
UnsignedEvent,
1111
finishEvent,
12-
Kind,
1312
} from "nostr-tools";
1413
import {
1514
GetBalanceResponse,
@@ -47,6 +46,12 @@ export type ListTransactionsResponse = {
4746
// TODO: consider moving to webln-types package
4847
export type ListTransactionsArgs = Nip47ListTransactionsArgs;
4948

49+
// TODO: consider moving to webln-types package
50+
export type SendMultiPaymentResponse = {
51+
payments: ({ paymentRequest: string } & SendPaymentResponse)[];
52+
errors: { paymentRequest: string; message: string }[];
53+
};
54+
5055
interface Nip47ListTransactionsArgs {
5156
from?: number;
5257
until?: number;
@@ -110,6 +115,9 @@ const nip47ToWeblnRequestMap = {
110115
lookup_invoice: "lookupInvoice",
111116
list_transactions: "listTransactions",
112117
};
118+
const nip47ToWeblnMultiRequestMap = {
119+
multi_pay_invoice: "sendMultiPayment",
120+
};
113121

114122
export class NostrWebLNProvider implements WebLNProvider, Nip07Provider {
115123
relay: Relay;
@@ -338,6 +346,45 @@ export class NostrWebLNProvider implements WebLNProvider, Nip07Provider {
338346
);
339347
}
340348

349+
// NOTE: this method may change - it has not been proposed to be added to the WebLN spec yet.
350+
async sendMultiPayment(
351+
paymentRequests: string[],
352+
): Promise<SendMultiPaymentResponse> {
353+
await this.checkConnected();
354+
355+
const results = await this.executeMultiNip47Request<
356+
{ preimage: string; paymentRequest: string },
357+
Nip47PayResponse
358+
>(
359+
"multi_pay_invoice",
360+
{
361+
invoices: paymentRequests.map((paymentRequest, index) => ({
362+
invoice: paymentRequest,
363+
id: index.toString(),
364+
})),
365+
},
366+
paymentRequests.length,
367+
(result) => !!result.preimage,
368+
(result) => {
369+
const paymentRequest = paymentRequests[parseInt(result.dTag)];
370+
if (!paymentRequest) {
371+
throw new Error(
372+
"Could not find paymentRequest matching response d tag",
373+
);
374+
}
375+
return {
376+
paymentRequest,
377+
preimage: result.preimage,
378+
};
379+
},
380+
);
381+
382+
return {
383+
payments: results,
384+
errors: [],
385+
};
386+
}
387+
341388
async keysend(args: KeysendArgs) {
342389
await this.checkConnected();
343390

@@ -559,7 +606,7 @@ export class NostrWebLNProvider implements WebLNProvider, Nip07Provider {
559606
JSON.stringify(command),
560607
);
561608
const unsignedEvent: UnsignedEvent = {
562-
kind: 23194 as Kind,
609+
kind: 23194,
563610
created_at: Math.floor(Date.now() / 1000),
564611
tags: [["p", this.walletPubkey]],
565612
content: encryptedCommand,
@@ -604,7 +651,6 @@ export class NostrWebLNProvider implements WebLNProvider, Nip07Provider {
604651
reject({ error: "invalid response", code: "INTERNAL" });
605652
return;
606653
}
607-
// @ts-ignore // event is still unknown in nostr-tools
608654
if (event.kind == 23195 && response.result) {
609655
// console.info("NIP-47 result", response.result);
610656
if (resultValidator(response.result)) {
@@ -644,6 +690,138 @@ export class NostrWebLNProvider implements WebLNProvider, Nip07Provider {
644690
})();
645691
});
646692
}
693+
694+
// TODO: this method currently fails if any payment fails.
695+
// this could be improved in the future.
696+
// TODO: reduce duplication between executeNip47Request and executeMultiNip47Request
697+
private executeMultiNip47Request<T, R>(
698+
nip47Method: keyof typeof nip47ToWeblnMultiRequestMap,
699+
params: unknown,
700+
numPayments: number,
701+
resultValidator: (result: R) => boolean,
702+
resultMapper: (result: R & { dTag: string }) => T,
703+
) {
704+
const weblnMethod = nip47ToWeblnMultiRequestMap[nip47Method];
705+
const results: (R & { dTag: string })[] = [];
706+
return new Promise<T[]>((resolve, reject) => {
707+
(async () => {
708+
const command = {
709+
method: nip47Method,
710+
params,
711+
};
712+
const encryptedCommand = await this.encrypt(
713+
this.walletPubkey,
714+
JSON.stringify(command),
715+
);
716+
const unsignedEvent: UnsignedEvent = {
717+
kind: 23194,
718+
created_at: Math.floor(Date.now() / 1000),
719+
tags: [["p", this.walletPubkey]],
720+
content: encryptedCommand,
721+
pubkey: this.publicKey,
722+
};
723+
724+
const event = await this.signEvent(unsignedEvent);
725+
// subscribe to NIP_47_SUCCESS_RESPONSE_KIND and NIP_47_ERROR_RESPONSE_KIND
726+
// that reference the request event (NIP_47_REQUEST_KIND)
727+
const sub = this.relay.sub([
728+
{
729+
kinds: [23195],
730+
authors: [this.walletPubkey],
731+
"#e": [event.id],
732+
},
733+
]);
734+
735+
function replyTimeout() {
736+
sub.unsub();
737+
//console.error(`Reply timeout: event ${event.id} `);
738+
reject({
739+
error: `reply timeout: event ${event.id}`,
740+
code: "INTERNAL",
741+
});
742+
}
743+
744+
const replyTimeoutCheck = setTimeout(replyTimeout, 60000);
745+
746+
sub.on("event", async (event) => {
747+
// console.log(`Received reply event: `, event);
748+
749+
const decryptedContent = await this.decrypt(
750+
this.walletPubkey,
751+
event.content,
752+
);
753+
// console.log(`Decrypted content: `, decryptedContent);
754+
let response;
755+
try {
756+
response = JSON.parse(decryptedContent);
757+
} catch (e) {
758+
console.error(e);
759+
clearTimeout(replyTimeoutCheck);
760+
sub.unsub();
761+
reject({ error: "invalid response", code: "INTERNAL" });
762+
return;
763+
}
764+
if (event.kind == 23195 && response.result) {
765+
// console.info("NIP-47 result", response.result);
766+
try {
767+
if (!resultValidator(response.result)) {
768+
throw new Error(
769+
"Response from NWC failed validation: " +
770+
JSON.stringify(response.result),
771+
);
772+
}
773+
const dTag = event.tags.find((tag) => tag[0] === "d")?.[1];
774+
if (dTag === undefined) {
775+
throw new Error("No d tag found in response event");
776+
}
777+
results.push({
778+
...response.result,
779+
dTag,
780+
});
781+
if (results.length === numPayments) {
782+
clearTimeout(replyTimeoutCheck);
783+
sub.unsub();
784+
//console.log("Received results", results);
785+
resolve(results.map(resultMapper));
786+
this.notify(weblnMethod, response.result);
787+
}
788+
} catch (error) {
789+
console.error(error);
790+
clearTimeout(replyTimeoutCheck);
791+
sub.unsub();
792+
reject({
793+
error: (error as Error).message,
794+
code: "INTERNAL",
795+
});
796+
}
797+
} else {
798+
clearTimeout(replyTimeoutCheck);
799+
sub.unsub();
800+
reject({
801+
error: response.error?.message,
802+
code: response.error?.code,
803+
});
804+
}
805+
});
806+
807+
function publishTimeout() {
808+
//console.error(`Publish timeout: event ${event.id}`);
809+
reject({ error: `Publish timeout: event ${event.id}` });
810+
}
811+
const publishTimeoutCheck = setTimeout(publishTimeout, 5000);
812+
813+
try {
814+
await this.relay.publish(event);
815+
clearTimeout(publishTimeoutCheck);
816+
//console.debug(`Event ${event.id} for ${invoice} published`);
817+
} catch (error) {
818+
//console.error(`Failed to publish to ${this.relay.url}`, error);
819+
clearTimeout(publishTimeoutCheck);
820+
reject({ error: `Failed to publish request: ${error}` });
821+
}
822+
})();
823+
});
824+
}
647825
}
648826

649827
function mapNip47TransactionToTransaction(

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1470,6 +1470,11 @@
14701470
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.47.0.tgz#5478fdf443ff8158f9de171c704ae45308696c7d"
14711471
integrity sha512-P6omY1zv5MItm93kLM8s2vr1HICJH8v0dvddDhysbIuZ+vcjOHg5Zbkf1mTkcmi2JA9oBG2anOkRnW8WJTS8Og==
14721472

1473+
"@getalby/lightning-tools@^5.0.1":
1474+
version "5.0.1"
1475+
resolved "https://registry.yarnpkg.com/@getalby/lightning-tools/-/lightning-tools-5.0.1.tgz#08a974bcdf3d98a86ff9909df838360ee67174c8"
1476+
integrity sha512-xoBfBYMQrJqwryU9fAYGIW6dzWRpdsAw8rroqTROba2bHdYT0ZvGnt4tjqXUhRswopR2X+wp1QeeWHZNL9A0Kg==
1477+
14731478
"@humanwhocodes/config-array@^0.11.10":
14741479
version "0.11.10"
14751480
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.10.tgz#5a3ffe32cc9306365fb3fd572596cd602d5e12d2"

0 commit comments

Comments
 (0)