diff --git a/.gitignore b/.gitignore index 7a510cf..9ecd977 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .vscode .aleph dist +addinvoice_payload.json config/config.ts pageviews.json diff --git a/api.ts b/api.ts index a9a8024..02ecc2b 100644 --- a/api.ts +++ b/api.ts @@ -1,5 +1,8 @@ import { Router } from "https://deno.land/x/oak/mod.ts"; import { exec, OutputMode } from "https://deno.land/x/exec/mod.ts"; +import { ensureFile } from "https://deno.land/std/fs/mod.ts"; +import * as base64 from "https://denopkg.com/chiefbiiko/base64/mod.ts"; +import { sha256 } from "https://denopkg.com/chiefbiiko/sha256/mod.ts"; import { getBlocks } from "./blocks.ts"; import { getblockchaininfo, getblockcount, getblockhash } from "./jsonrpc/index.ts"; @@ -41,7 +44,8 @@ router.get("/blocks", (context) => { context.response.body = blocks; }); -router.get("/invoice", async (context) => { +const responseMetadata = JSON.stringify([["text/plain", "Donation on taproot.watch"]]); +router.get("/invoice", (context) => { if (!config.donation) { context.response.status = 400; context.response.body = JSON.stringify({ @@ -51,14 +55,60 @@ router.get("/invoice", async (context) => { return; } + context.response.body = JSON.stringify({ + tag: "payRequest", + callback: `${config.donation.lnurlPayUrl}/callback`, + maxSendable: 100000, + minSendable: 1000, + metadata: responseMetadata, + // commentAllowed: 200, + }); +}); + +await ensureFile("./addinvoice_payload.json"); +router.get("/invoice/callback", async (context) => { + if (!config.donation) { + context.response.status = 400; + context.response.body = JSON.stringify({ + status: "ERROR", + reason: "Donation is not configured", + }); + return; + } + + const amountString = context.request.url.searchParams.get("amount"); + if (amountString === null) { + context.response.body = JSON.stringify({ + status: "ERROR", + reason: "Missing amount parameter", + }); + return; + } + const amount = Number.parseInt(amountString); + + await Deno.writeTextFile( + "./addinvoice_payload.json", + JSON.stringify({ + memo: "Donation", + value_msat: amount, + description_hash: sha256(responseMetadata, "utf8", "base64"), + }) + ); + const macaroonHeader = bytesToHexString(await Deno.readFile(config.donation.data.macaroon)); - // -d '{"memo":"Donation"}' - const command = `curl -X POST --cacert ${config.donation.data.cert} --header "Grpc-Metadata-macaroon: ${macaroonHeader}" ${config.donation.data.server}/v1/invoices`; + const command = `curl -X POST --cacert ${config.donation.data.cert} --header "Grpc-Metadata-macaroon: ${macaroonHeader}" -d @addinvoice_payload.json ${config.donation.data.server}/v1/invoices`; const result = await exec(command, { output: OutputMode.Capture, }); - context.response.body = JSON.parse(result.output).payment_request; + const paymentRequest = JSON.parse(result.output).payment_request; + context.response.body = JSON.stringify({ + pr: paymentRequest, + successAction: { + tag: "message", + message: "Cheers!", + }, + disposable: true, + }); }); - export default router; diff --git a/config/config.ts_TEMPLATE b/config/config.ts_TEMPLATE index 536ba44..cbaf829 100644 --- a/config/config.ts_TEMPLATE +++ b/config/config.ts_TEMPLATE @@ -43,6 +43,8 @@ interface Config { // Path to invoice.macaroon macaroon: string; }; + // URL to the LNURL-pay endpoint + lnurlPayUrl: string; }; } @@ -73,6 +75,7 @@ const config: Config = { // macaroon: "/path/to/invoice.macaroon", // }, // }, + // lnurlPayUrl: "https://domain.com/invoice", }; export default config; diff --git a/frontend/components/Donation.tsx b/frontend/components/Donation.tsx index 665d90c..786be31 100644 --- a/frontend/components/Donation.tsx +++ b/frontend/components/Donation.tsx @@ -1,44 +1,115 @@ import React, { useState } from "https://esm.sh/react@17.0.2"; import styled from "https://esm.sh/styled-components"; import { QRCode } from "https://esm.sh/react-qr-svg"; +import { bech32 } from "https://esm.sh/bech32"; + +import config from "../back/config/config.ts"; +import { bytesToString } from "../back/utils.ts"; export const DonateContainer = styled.div` + margin: 0 auto 100px; + width: 400px; text-align: center; - margin-bottom: 100px; `; export const DonateText = styled.a` + display: block; text-align: center; color: #404040; text-shadow: #000 1px 1px 0px; cursor: pointer; `; +export const Bolt11Text = styled.a` + display: block; + overflow-wrap: anywhere; + font-size: 11px; + color: #aaa; + cursor: pointer; + margin-top: 5px; + margin-bottom: 15px; +`; + +export const InvoiceText = styled.p` + text-align: center; + color: #aaa; + text-shadow: #000 1px 1px 0px; +`; + +export const ChangeToBolt11 = styled.a` + display: block; + text-align: center; + color: #aaa; + text-shadow: #000 1px 1px 0px; + cursor: pointer; + text-decoration: underline; + font-size: 14px; +`; + +const lnurlPayBech32 = bech32.encode( + "lnurl", + bech32.toWords(new TextEncoder().encode(config.donation?.lnurlPayUrl)), + 1024 +); + export function Donation() { + const [type, setType] = useState<"lnurl-pay" | "bolt11">("lnurl-pay"); const [invoice, setInvoice] = useState(undefined); - const fetchInvoice = async () => { - const result = await fetch("/invoice"); - setInvoice(await result.text()); + const showLnUrlPay = () => { + setType("lnurl-pay"); + setInvoice(lnurlPayBech32); + }; + + const decodeLnUrlPay = async () => { + const decodedBech32 = bech32.decode(invoice!, 1024); + const decodedUrl = bytesToString(bech32.fromWords(decodedBech32.words)); + + const result = await fetch(decodedUrl); + const resultJson = await result.json(); + + const amount = prompt( + `Choose an amount between ${resultJson.minSendable} sats and ${resultJson.maxSendable} sats`, + resultJson.minSendable + ); + + const callback = resultJson.callback; + + const resultCallback = await fetch(callback + "?amount=" + amount); + const resultCallbackJson = await resultCallback.json(); + + setType("bolt11"); + setInvoice(resultCallbackJson.pr); + }; + + const onClickInvoice = () => { + window.location.replace("lightning:" + invoice); }; return ( - {!invoice && Donate via Lightning Network} + {!invoice && Donate via Lightning Network} {invoice && ( -
{ - window.location.replace("lightning:" + invoice); - }} - > - -
+ <> + + {type === "lnurl-pay" && <>LNURL-pay} + {type === "bolt11" && <>LN Invoice} QR code: + +
+ +
+ {invoice} + {type == "lnurl-pay" && ( + + Unsupported wallet? Click to change to normal BOLT11 invoice + + )} + )}
); diff --git a/frontend/pages/index.tsx b/frontend/pages/index.tsx index eac28ed..01065b7 100644 --- a/frontend/pages/index.tsx +++ b/frontend/pages/index.tsx @@ -114,7 +114,7 @@ export default function Blocks() { ))} - + {config.donation && } ); diff --git a/utils.ts b/utils.ts index e05c02a..8bb834e 100644 --- a/utils.ts +++ b/utils.ts @@ -3,3 +3,7 @@ export const bytesToHexString = (bytes: Uint8Array) => { return memo + ("0" + i.toString(16)).slice(-2); //padd with leading 0 if <16 }, ""); }; + +export const bytesToString = (bytes: number[]) => { + return String.fromCharCode.apply(null, bytes); +};