From 9d51641be5d1a46d8569f509db0f077b6e0bc8b7 Mon Sep 17 00:00:00 2001 From: UncleSamtoshi <88598461+UncleSamtoshi@users.noreply.github.com> Date: Fri, 17 Feb 2023 02:48:38 -0600 Subject: [PATCH] feat: support nostr zap requests (#414) * feat: support nostr zap requests * chore: make time expiry default Galoy settings of 1 day --------- Co-authored-by: nicolasburtey --- lib/config.ts | 4 +- package.json | 1 + pages/api/lnurlp/[username].ts | 172 +++++++++++++++++++++++---------- yarn.lock | 57 +++++++++++ 4 files changed, 184 insertions(+), 50 deletions(-) diff --git a/lib/config.ts b/lib/config.ts index e9a6fe9f..d321d10a 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -24,4 +24,6 @@ const GRAPHQL_URI_INTERNAL = `http://${GRAPHQL_HOSTNAME_INTERNAL}/graphql` const GRAPHQL_URI = `https://${GRAPHQL_HOSTNAME}/graphql` const GRAPHQL_SUBSCRIPTION_URI = `wss://${GRAPHQL_HOSTNAME}/graphql` -export { GRAPHQL_URI, GRAPHQL_SUBSCRIPTION_URI, GRAPHQL_URI_INTERNAL } +const NOSTR_PUBKEY = process.env.NOSTR_PUBKEY as string + +export { GRAPHQL_URI, GRAPHQL_SUBSCRIPTION_URI, GRAPHQL_URI_INTERNAL, NOSTR_PUBKEY } diff --git a/package.json b/package.json index 65075220..ab45837c 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "graphql": "^16.6.0", "html2canvas": "^1.4.1", "i18n-js": "^3.9.2", + "ioredis": "^5.3.1", "lnurl-pay": "^1.0.1", "lodash.debounce": "^4.0.8", "next": "^12.3.0", diff --git a/pages/api/lnurlp/[username].ts b/pages/api/lnurlp/[username].ts index ebaa09eb..3d75923c 100644 --- a/pages/api/lnurlp/[username].ts +++ b/pages/api/lnurlp/[username].ts @@ -9,8 +9,9 @@ import { InMemoryCache, } from "@apollo/client" import type { NextApiRequest, NextApiResponse } from "next" +import Redis from "ioredis" -import { GRAPHQL_URI_INTERNAL } from "../../../lib/config" +import { GRAPHQL_URI_INTERNAL, NOSTR_PUBKEY } from "../../../lib/config" const ipForwardingMiddleware = new ApolloLink((operation, forward) => { operation.setContext(({ headers = {} }) => ({ @@ -62,75 +63,114 @@ const LNURL_INVOICE = gql` } invoice { paymentRequest + paymentHash } } } ` type CreateInvoiceOutput = { - paymentRequest?: string - error?: Error + invoice?: { + paymentRequest: string + paymentHash: string + } + errors?: { + message: string + }[] } -async function createInvoice( - walletId: string, - amount: number, - metadata: string, -): Promise { - const descriptionHash = crypto.createHash("sha256").update(metadata).digest("hex") +type CreateInvoiceParams = { + walletId: string + amount: number + descriptionHash: string +} +async function createInvoice(params: CreateInvoiceParams): Promise { const { data: { mutationData: { errors, invoice }, }, } = await client.mutate({ mutation: LNURL_INVOICE, - variables: { - walletId, - amount, - descriptionHash, - }, + variables: params, }) - if (errors && errors.length) { - throw new Error(`Failed to get invoice: ${errors[0].message}`) + + return { errors, invoice } +} + +const nostrEnabled = !!NOSTR_PUBKEY + +let redis: Redis | null = null + +if (nostrEnabled) { + const connectionObj = { + sentinelPassword: process.env.REDIS_PASSWORD, + sentinels: [ + { + host: `${process.env.REDIS_0_DNS}`, + port: Number(process.env.REDIS_0_SENTINEL_PORT) || 26379, + }, + { + host: `${process.env.REDIS_1_DNS}`, + port: Number(process.env.REDIS_1_SENTINEL_PORT) || 26379, + }, + { + host: `${process.env.REDIS_2_DNS}`, + port: Number(process.env.REDIS_2_SENTINEL_PORT) || 26379, + }, + ], + name: process.env.REDIS_MASTER_NAME ?? "mymaster", + password: process.env.REDIS_PASSWORD, } - return invoice + + redis = new Redis(connectionObj) + + redis.on("error", (err) => console.log({ err }, "Redis error")) } export default async function (req: NextApiRequest, res: NextApiResponse) { - const { username, amount } = req.query + console.log(NOSTR_PUBKEY) + + const { username, amount, nostr } = req.query const url = originalUrl(req) + const accountUsername = username ? username.toString() : "" - console.log({ headers: req.headers }, "request to NextApiRequest") + const walletId = await getUserWalletId(accountUsername, req) + if (!walletId) { + return res.json({ + status: "ERROR", + reason: `Couldn't find user '${username}'.`, + }) + } - const accountUsername = username ? username.toString() : "" const metadata = JSON.stringify([ - ["text/plain", `Payment to ${username}`], - ["text/identifier", `${username}@${url.hostname}`], + ["text/plain", `Payment to ${accountUsername}`], + ["text/identifier", `${accountUsername}@${url.hostname}`], ]) - let walletId: string - try { - const { data } = await client.query({ - query: ACCOUNT_DEFAULT_WALLET, - variables: { username: accountUsername, walletCurrency: "BTC" }, - context: { - "x-real-ip": req.headers["x-real-ip"], - "x-forwarded-for": req.headers["x-forwarded-for"], - }, - }) - walletId = data?.accountDefaultWallet?.id ? data?.accountDefaultWallet?.id : "" - } catch (err) { + // lnurl options call + if (!amount) { return res.json({ - status: "ERROR", - reason: `Couldn't find user '${username}'.`, + callback: url.full, + minSendable: 1000, + maxSendable: 100000000000, + metadata: metadata, + tag: "payRequest", + ...(nostrEnabled + ? { + allowsNostr: true, + nostrPubkey: NOSTR_PUBKEY, + } + : {}), }) } - if (amount) { - if (Array.isArray(amount)) { + // lnurl generate invoice + try { + if (Array.isArray(amount) || Array.isArray(nostr)) { throw new Error("Invalid request") } + const amountSats = Math.round(parseInt(amount, 10) / 1000) if ((amountSats * 1000).toString() !== amount) { return res.json({ @@ -138,25 +178,59 @@ export default async function (req: NextApiRequest, res: NextApiResponse) { reason: "Millisatoshi amount is not supported, please send a value in full sats.", }) } - const { paymentRequest, error } = await createInvoice(walletId, amountSats, metadata) - if (error instanceof Error) { + + let descriptionHash: string + + if (nostrEnabled && nostr) { + descriptionHash = crypto.createHash("sha256").update(nostr).digest("hex") + } else { + descriptionHash = crypto.createHash("sha256").update(metadata).digest("hex") + } + + const { invoice, errors } = await createInvoice({ + walletId, + amount: amountSats, + descriptionHash, + }) + + if ((errors && errors.length) || !invoice) { + console.log("error getting invoice", errors) return res.json({ status: "ERROR", - reason: error instanceof Error ? error.message : "unexpected error", + reason: `Failed to get invoice: ${errors ? errors[0].message : "unknown error"}`, }) } + + if (nostrEnabled && nostr && redis) { + redis.set(`nostrInvoice:${invoice.paymentHash}`, nostr, "EX", 1440) + } + return res.json({ - pr: paymentRequest, + pr: invoice.paymentRequest, routes: [], }) - } else { - // first call + } catch (err: unknown) { + console.log("unexpected error getting invoice", err) res.json({ - callback: url.full, - minSendable: 1000, - maxSendable: 100000000000, - metadata: metadata, - tag: "payRequest", + status: "ERROR", + reason: err instanceof Error ? err.message : "unexpected error", + }) + } +} + +const getUserWalletId = async (accountUsername: string, req: NextApiRequest) => { + try { + const { data } = await client.query({ + query: ACCOUNT_DEFAULT_WALLET, + variables: { username: accountUsername, walletCurrency: "BTC" }, + context: { + "x-real-ip": req.headers["x-real-ip"], + "x-forwarded-for": req.headers["x-forwarded-for"], + }, }) + return data?.accountDefaultWallet?.id + } catch (err) { + console.log("error getting user wallet id:", err) + return undefined } } diff --git a/yarn.lock b/yarn.lock index 293238b6..1bd64842 100644 --- a/yarn.lock +++ b/yarn.lock @@ -125,6 +125,11 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== +"@ioredis/commands@^1.1.1": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" + integrity sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg== + "@next/env@12.3.0": version "12.3.0" resolved "https://registry.yarnpkg.com/@next/env/-/env-12.3.0.tgz#85f971fdc668cc312342761057c59cb8ab1abadf" @@ -1170,6 +1175,11 @@ classnames@^2.3.1: resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e" integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA== +cluster-key-slot@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac" + integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -1349,6 +1359,11 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== +denque@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1" + integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== + dequal@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" @@ -2216,6 +2231,21 @@ invariant@^2.2.4: dependencies: loose-envify "^1.0.0" +ioredis@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-5.3.1.tgz#55d394a51258cee3af9e96c21c863b1a97bf951f" + integrity sha512-C+IBcMysM6v52pTLItYMeV4Hz7uriGtoJdz7SSBDX6u+zwSYGirLdQh3L7t/OItWITcw3gTFMjJReYUwS4zihg== + dependencies: + "@ioredis/commands" "^1.1.1" + cluster-key-slot "^1.1.0" + debug "^4.3.4" + denque "^2.1.0" + lodash.defaults "^4.2.0" + lodash.isarguments "^3.1.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + standard-as-callback "^2.1.0" + is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" @@ -2513,6 +2543,16 @@ lodash.debounce@^4.0.8: resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== +lodash.defaults@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" + integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ== + +lodash.isarguments@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" + integrity sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg== + lodash.isequal@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" @@ -3198,6 +3238,18 @@ recursive-readdir@^2.2.2: dependencies: minimatch "3.0.4" +redis-errors@^1.0.0, redis-errors@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad" + integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w== + +redis-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4" + integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A== + dependencies: + redis-errors "^1.0.0" + regenerator-runtime@^0.11.0: version "0.11.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" @@ -3394,6 +3446,11 @@ source-map@^0.7.3: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656" integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA== +standard-as-callback@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45" + integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A== + stream-browserify@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f"