diff --git a/functions/graphql/resolvers.js b/functions/graphql/resolvers.js index e9a2f3d..df57721 100644 --- a/functions/graphql/resolvers.js +++ b/functions/graphql/resolvers.js @@ -1,45 +1,59 @@ -const { parsePaymentRequest } = require('invoices'); -const axios = require('axios'); -const { createHash } = require('crypto'); +const { parsePaymentRequest } = require("invoices"); +const axios = require("axios"); +const { createHash } = require("crypto"); -function hexToUint8Array (hexString) { +function hexToUint8Array(hexString) { const match = hexString.match(/.{1,2}/g); if (match) { return new Uint8Array(match.map((byte) => parseInt(byte, 16))); } } -// TODO validate responses -function getPaymetRequest(lightning_address, amount_in_sat) { +// TODO: generaly validate LNURL responses + +function getLnurlDetails(lnurl) { + return axios.get(lnurl); +} + +function lightningAddressToLnurl(lightning_address) { const [name, domain] = lightning_address.split("@"); - const lnurl = `https://${domain}/.well-known/lnurlp/${name}`; - return axios.get(lnurl) - .then((response) => { - console.log(response.data); - const callbackUrl = response.data.callback; - const amount = amount_in_sat * 1000; // msats - return axios.get(callbackUrl, { params: { amount }} ) - .then(prResponse => { - console.log(prResponse.data); - return prResponse.data.pr; - }); - }) - .catch(function (error) { - console.error(error); - }) + return `https://${domain}/.well-known/lnurlp/${name}`; +} + +async function getLnurlCallbackUrl(lightning_address) { + return getLnurlDetails(lightningAddressToLnurl(lightning_address)).then( + (response) => { + return response.data.callback; + } + ); +} + +async function getPaymetRequestForProject(project, amount_in_sat) { + let lnurlCallbackUrl = project.lnurl_callback_url; + const amount = amount_in_sat * 1000; // msats + console.log(lnurlCallbackUrl); + if (!lnurlCallbackUrl) { + lnurlCallbackUrl = await getLnurlCallbackUrl(project.lightning_address); + } + return axios + .get(lnurlCallbackUrl, { params: { amount } }) + .then((prResponse) => { + console.log(prResponse.data); + return prResponse.data.pr; + }); } module.exports = { Query: { allCategories: async (_source, args, context) => { return context.prisma.category.findMany({ - orderBy: { title: 'desc'}, + orderBy: { title: "desc" }, include: { project: { take: 5, - orderBy: { votes_count: "desc" } - } - } + orderBy: { votes_count: "desc" }, + }, + }, }); }, getCategory: async (_source, args, context) => { @@ -48,16 +62,16 @@ module.exports = { include: { project: { take: 5, - orderBy: { votes_count: "desc" } - } - } + orderBy: { votes_count: "desc" }, + }, + }, }); }, newProjects: async (_source, args, context) => { const take = args.take || 50; const skip = args.skip || 0; return context.prisma.project.findMany({ - orderBy: { created_at: 'desc' }, + orderBy: { created_at: "desc" }, include: { category: true }, skip, take, @@ -67,7 +81,7 @@ module.exports = { const take = args.take || 50; const skip = args.skip || 0; return context.prisma.project.findMany({ - orderBy: { votes_count: 'desc' }, + orderBy: { votes_count: "desc" }, include: { category: true }, skip, take, @@ -79,7 +93,7 @@ module.exports = { const categoryId = args.category_id; return context.prisma.project.findMany({ where: { category_id: categoryId }, - orderBy: { votes_count: 'desc' }, + orderBy: { votes_count: "desc" }, include: { category: true }, skip, take, @@ -90,14 +104,47 @@ module.exports = { where: { id: args.id, }, - include: { category: true } + include: { category: true }, }); }, + getLnurlDetailsForProject: async (_source, args, context) => { + const project = await context.prisma.project.findUnique({ + where: { + id: args.project_id, + }, + }); + const lnurlDetails = await getLnurlDetails( + lightningAddressToLnurl(project.lightning_address) + ); + if ( + !lnurlDetails.data || + lnurlDetails.data.status.toLowerCase() !== "ok" + ) { + console.error(lnurlDetails.data); + throw new Error("Recipient not available"); + } + + // cache the callback URL + await context.prisma.project.update({ + where: { id: project.id }, + data: { + lnurl_callback_url: lnurlDetails.data.callback, + }, + }); + return { + minSendable: parseInt(lnurlDetails.data.minSendable) / 1000, + maxSendable: parseInt(lnurlDetails.data.maxSendable) / 1000, + metadata: lnurlDetails.data.metadata, + commentAllowed: lnurlDetails.data.commentAllowed, + }; + }, }, Mutation: { vote: async (_source, args, context) => { - const project = await context.prisma.project.findUnique({where: { id: args.project_id }}); - const pr = await getPaymetRequest(project.lightning_address, args.amount_in_sat); + const project = await context.prisma.project.findUnique({ + where: { id: args.project_id }, + }); + const pr = await getPaymetRequestForProject(project, args.amount_in_sat); const invoice = parsePaymentRequest({ request: pr }); return context.prisma.vote.create({ data: { @@ -105,23 +152,32 @@ module.exports = { amount_in_sat: args.amount_in_sat, payment_request: pr, payment_hash: invoice.id, - } + }, }); }, confirmVote: async (_source, args, context) => { - const paymentHash = createHash('sha256').update(hexToUint8Array(args.preimage)).digest('hex'); + const paymentHash = createHash("sha256") + .update(hexToUint8Array(args.preimage)) + .digest("hex"); // look for a vote for the payment request and the calculated payment hash - const vote = await context.prisma.vote.findFirst({where: { payment_request: args.payment_request, payment_hash: paymentHash}}); + const vote = await context.prisma.vote.findFirst({ + where: { + payment_request: args.payment_request, + payment_hash: paymentHash, + }, + }); // if we find a vote it means the preimage is correct and we update the vote and mark it as paid // can we write this nicer? if (vote) { - const project = await context.prisma.project.findUnique({ where: { id: vote.project_id }}); + const project = await context.prisma.project.findUnique({ + where: { id: vote.project_id }, + }); // count up votes cache await context.prisma.project.update({ - where: {id: project.id }, + where: { id: project.id }, data: { - votes_count: project.votes_count = vote.amount_in_sat, - } + votes_count: (project.votes_count = vote.amount_in_sat), + }, }); // return the current vote return context.prisma.vote.update({ @@ -129,11 +185,11 @@ module.exports = { data: { paid: true, preimage: args.preimage, - } + }, }); } else { throw new Error("Invalid preimage"); } - } + }, }, }; diff --git a/functions/graphql/typeDefs.js b/functions/graphql/typeDefs.js index c96f23b..82b49f5 100644 --- a/functions/graphql/typeDefs.js +++ b/functions/graphql/typeDefs.js @@ -27,6 +27,13 @@ module.exports = gql` paid: Boolean! } + type LnurlDetails { + minSendable: Int + maxSendable: Int + metadata: String + commentAllowed: Int + } + type Query { allProjects(skip: Int, take: Int): [Project]! newProjects(skip: Int, take: Int): [Project]! @@ -34,9 +41,10 @@ module.exports = gql` getProject(id: Int!): Project! allCategories: [Category]! getCategory(id: Int!): Category! + getLnurlDetailsForProject(project_id: Int!): LnurlDetails! } type Mutation { - vote (project_id: Int!, amount_in_sat: Int!): Vote! - confirmVote (payment_request: String!, preimage: String!): Vote! + vote(project_id: Int!, amount_in_sat: Int!): Vote! + confirmVote(payment_request: String!, preimage: String!): Vote! } `; diff --git a/prisma/migrations/20211129045548_add_lnurl_callback_url_to_project/migration.sql b/prisma/migrations/20211129045548_add_lnurl_callback_url_to_project/migration.sql new file mode 100644 index 0000000..8045323 --- /dev/null +++ b/prisma/migrations/20211129045548_add_lnurl_callback_url_to_project/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "lnurl_callback_url" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 87cd39e..40179d0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,18 +14,19 @@ model Category { } model Project { - id Int @id @default(autoincrement()) - title String - description String - website String - thumbnail_image String? - cover_image String? - lightning_address String? - category Category @relation(fields: [category_id], references: [id]) - category_id Int - votes_count Int @default(0) - vote Vote[] - created_at DateTime @default(now()) + id Int @id @default(autoincrement()) + title String + description String + website String + thumbnail_image String? + cover_image String? + lightning_address String? + lnurl_callback_url String? + category Category @relation(fields: [category_id], references: [id]) + category_id Int + votes_count Int @default(0) + vote Vote[] + created_at DateTime @default(now()) } model Vote {