|
9 | 9 | Event,
|
10 | 10 | UnsignedEvent,
|
11 | 11 | finishEvent,
|
12 |
| - Kind, |
13 | 12 | } from "nostr-tools";
|
14 | 13 | import {
|
15 | 14 | GetBalanceResponse,
|
@@ -47,6 +46,12 @@ export type ListTransactionsResponse = {
|
47 | 46 | // TODO: consider moving to webln-types package
|
48 | 47 | export type ListTransactionsArgs = Nip47ListTransactionsArgs;
|
49 | 48 |
|
| 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 | + |
50 | 55 | interface Nip47ListTransactionsArgs {
|
51 | 56 | from?: number;
|
52 | 57 | until?: number;
|
@@ -110,6 +115,9 @@ const nip47ToWeblnRequestMap = {
|
110 | 115 | lookup_invoice: "lookupInvoice",
|
111 | 116 | list_transactions: "listTransactions",
|
112 | 117 | };
|
| 118 | +const nip47ToWeblnMultiRequestMap = { |
| 119 | + multi_pay_invoice: "sendMultiPayment", |
| 120 | +}; |
113 | 121 |
|
114 | 122 | export class NostrWebLNProvider implements WebLNProvider, Nip07Provider {
|
115 | 123 | relay: Relay;
|
@@ -338,6 +346,45 @@ export class NostrWebLNProvider implements WebLNProvider, Nip07Provider {
|
338 | 346 | );
|
339 | 347 | }
|
340 | 348 |
|
| 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 | + |
341 | 388 | async keysend(args: KeysendArgs) {
|
342 | 389 | await this.checkConnected();
|
343 | 390 |
|
@@ -559,7 +606,7 @@ export class NostrWebLNProvider implements WebLNProvider, Nip07Provider {
|
559 | 606 | JSON.stringify(command),
|
560 | 607 | );
|
561 | 608 | const unsignedEvent: UnsignedEvent = {
|
562 |
| - kind: 23194 as Kind, |
| 609 | + kind: 23194, |
563 | 610 | created_at: Math.floor(Date.now() / 1000),
|
564 | 611 | tags: [["p", this.walletPubkey]],
|
565 | 612 | content: encryptedCommand,
|
@@ -604,7 +651,6 @@ export class NostrWebLNProvider implements WebLNProvider, Nip07Provider {
|
604 | 651 | reject({ error: "invalid response", code: "INTERNAL" });
|
605 | 652 | return;
|
606 | 653 | }
|
607 |
| - // @ts-ignore // event is still unknown in nostr-tools |
608 | 654 | if (event.kind == 23195 && response.result) {
|
609 | 655 | // console.info("NIP-47 result", response.result);
|
610 | 656 | if (resultValidator(response.result)) {
|
@@ -644,6 +690,138 @@ export class NostrWebLNProvider implements WebLNProvider, Nip07Provider {
|
644 | 690 | })();
|
645 | 691 | });
|
646 | 692 | }
|
| 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 | + } |
647 | 825 | }
|
648 | 826 |
|
649 | 827 | function mapNip47TransactionToTransaction(
|
|
0 commit comments