From b8080137a86a5b9a53d894bc294c1ab17f6cd9fb Mon Sep 17 00:00:00 2001 From: keyan Date: Thu, 28 Oct 2021 14:59:53 -0500 Subject: [PATCH] lnurl-withdrawal support --- api/resolvers/lnurl.js | 42 ++++++++--- api/resolvers/wallet.js | 4 -- api/ssrApollo.js | 6 +- api/typeDefs/lnurl.js | 11 +++ fragments/wallet.js | 7 ++ pages/api/lnwith.js | 71 +++++++++++++++++++ pages/wallet.js | 60 +++++++++++++--- .../20211027230800_lnwith/migration.sql | 14 ++++ .../migration.sql | 2 + prisma/schema.prisma | 9 +++ styles/globals.scss | 3 +- 11 files changed, 204 insertions(+), 25 deletions(-) create mode 100644 pages/api/lnwith.js create mode 100644 prisma/migrations/20211027230800_lnwith/migration.sql create mode 100644 prisma/migrations/20211027231327_lnwith_withid_optional/migration.sql diff --git a/api/resolvers/lnurl.js b/api/resolvers/lnurl.js index ddb42326..10b431a4 100644 --- a/api/resolvers/lnurl.js +++ b/api/resolvers/lnurl.js @@ -1,26 +1,52 @@ import { randomBytes } from 'crypto' import { bech32 } from 'bech32' +import { AuthenticationError } from 'apollo-server-micro' + +function encodedUrl (iurl, tag, k1) { + const url = new URL(iurl) + url.searchParams.set('tag', tag) + url.searchParams.set('k1', k1) + // bech32 encode url + const words = bech32.toWords(Buffer.from(url.toString(), 'utf8')) + return bech32.encode('lnurl', words, 1023) +} + +function k1 () { + return randomBytes(32).toString('hex') +} export default { Query: { lnAuth: async (parent, { k1 }, { models }) => { return await models.lnAuth.findUnique({ where: { k1 } }) + }, + lnWith: async (parent, { k1 }, { models }) => { + return await models.lnWith.findUnique({ where: { k1 } }) } }, Mutation: { createAuth: async (parent, args, { models }) => { - const k1 = randomBytes(32).toString('hex') - return await models.lnAuth.create({ data: { k1 } }) + return await models.lnAuth.create({ data: { k1: k1() } }) + }, + createWith: async (parent, args, { me, models }) => { + if (!me) { + throw new AuthenticationError('you must be logged in') + } + + return await models.lnWith.create({ data: { k1: k1(), userId: me.id } }) } }, LnAuth: { encodedUrl: async (lnAuth, args, { models }) => { - const url = new URL(process.env.LNAUTH_URL) - url.searchParams.set('tag', 'login') - url.searchParams.set('k1', lnAuth.k1) - // bech32 encode url - const words = bech32.toWords(Buffer.from(url.toString(), 'utf8')) - return bech32.encode('lnurl', words, 1023) + return encodedUrl(process.env.LNAUTH_URL, 'login', lnAuth.k1) + } + }, + LnWith: { + encodedUrl: async (lnWith, args, { models }) => { + return encodedUrl(process.env.LNWITH_URL, 'withdrawRequest', lnWith.k1) + }, + user: async (lnWith, args, { models }) => { + return await models.user.findUnique({ where: { id: lnWith.userId } }) } } } diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index cf3e3d2f..5ac5e84a 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -89,10 +89,6 @@ export default { } }, createWithdrawl: async (parent, { invoice, maxFee }, { me, models, lnd }) => { - if (!me) { - throw new AuthenticationError('you must be logged in') - } - // decode invoice to get amount let decoded try { diff --git a/api/ssrApollo.js b/api/ssrApollo.js index 2a1eb456..48133749 100644 --- a/api/ssrApollo.js +++ b/api/ssrApollo.js @@ -6,8 +6,9 @@ import resolvers from './resolvers' import typeDefs from './typeDefs' import models from './models' import { print } from 'graphql' +import lnd from './lnd' -export default async function getSSRApolloClient (req) { +export default async function getSSRApolloClient (req, me = null) { const session = req && await getSession({ req }) return new ApolloClient({ ssrMode: true, @@ -18,7 +19,8 @@ export default async function getSSRApolloClient (req) { }), context: { models, - me: session ? session.user : null + me: session ? session.user : me, + lnd } }), cache: new InMemoryCache() diff --git a/api/typeDefs/lnurl.js b/api/typeDefs/lnurl.js index 3d1b80a3..90e14193 100644 --- a/api/typeDefs/lnurl.js +++ b/api/typeDefs/lnurl.js @@ -3,10 +3,12 @@ import { gql } from 'apollo-server-micro' export default gql` extend type Query { lnAuth(k1: String!): LnAuth! + lnWith(k1: String!): LnWith! } extend type Mutation { createAuth: LnAuth! + createWith: LnWith! } type LnAuth { @@ -16,4 +18,13 @@ export default gql` pubkey: String encodedUrl: String! } + + type LnWith { + id: ID! + createdAt: String! + k1: String! + user: User! + withdrawalId: Int + encodedUrl: String! + } ` diff --git a/fragments/wallet.js b/fragments/wallet.js index c0766463..1ae330b3 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -23,3 +23,10 @@ export const WITHDRAWL = gql` status } }` + +export const CREATE_WITHDRAWL = gql` + mutation createWithdrawl($invoice: String!, $maxFee: Int!) { + createWithdrawl(invoice: $invoice, maxFee: $maxFee) { + id + } +}` diff --git a/pages/api/lnwith.js b/pages/api/lnwith.js new file mode 100644 index 00000000..4d50ec8d --- /dev/null +++ b/pages/api/lnwith.js @@ -0,0 +1,71 @@ +// verify k1 exists +// send back +import models from '../../api/models' +import getSSRApolloClient from '../../api/ssrApollo' +import { CREATE_WITHDRAWL } from '../../fragments/wallet' + +export default async ({ query }, res) => { + if (query.pr) { + return doWithdrawal(query, res) + } + + let reason + try { + // TODO: make sure lnwith was recently generated ... or better use a stateless + // bearer token to auth user + const lnwith = await models.lnWith.findUnique({ where: { k1: query.k1 } }) + if (lnwith) { + const user = await models.user.findUnique({ where: { id: lnwith.userId } }) + if (user) { + return res.status(200).json({ + tag: 'withdrawRequest', // type of LNURL + callback: process.env.LNWITH_URL, // The URL which LN SERVICE would accept a withdrawal Lightning invoice as query parameter + k1: query.k1, // Random or non-random string to identify the user's LN WALLET when using the callback URL + defaultDescription: `Withdrawal for @${user.name} on SN`, // A default withdrawal invoice description + minWithdrawable: 1000, // Min amount (in millisatoshis) the user can withdraw from LN SERVICE, or 0 + maxWithdrawable: user.msats - 10000 // Max amount (in millisatoshis) the user can withdraw from LN SERVICE, or equal to minWithdrawable if the user has no choice over the amounts + }) + } else { + reason = 'user not found' + } + } else { + reason = 'withdrawal not found' + } + } catch (error) { + console.log(error) + reason = 'internal server error' + } + + return res.status(400).json({ status: 'ERROR', reason }) +} + +async function doWithdrawal (query, res) { + if (!query.k1) { + return res.status(400).json({ status: 'ERROR', reason: 'k1 not provided' }) + } + + const lnwith = await models.lnWith.findUnique({ where: { k1: query.k1 } }) + if (!lnwith) { + return res.status(400).json({ status: 'ERROR', reason: 'invalid k1' }) + } + const me = await models.user.findUnique({ where: { id: lnwith.userId } }) + if (!me) { + return res.status(400).json({ status: 'ERROR', reason: 'user not found' }) + } + + // create withdrawal in gql + const client = await getSSRApolloClient(null, me) + const { error, data } = await client.mutate({ + mutation: CREATE_WITHDRAWL, + variables: { invoice: query.pr, maxFee: 10 } + }) + + if (error || !data?.createWithdrawl) { + return res.status(400).json({ status: 'ERROR', reason: error?.toString() || 'could not generate withdrawl' }) + } + + // store withdrawal id lnWith so client can show it + await models.lnWith.update({ where: { k1: query.k1 }, data: { withdrawalId: Number(data.createWithdrawl.id) } }) + + return res.status(200).json({ status: 'OK' }) +} diff --git a/pages/wallet.js b/pages/wallet.js index 2c898403..4c9a0fd1 100644 --- a/pages/wallet.js +++ b/pages/wallet.js @@ -3,8 +3,8 @@ import { Form, Input, SubmitButton } from '../components/form' import Link from 'next/link' import Button from 'react-bootstrap/Button' import * as Yup from 'yup' -import { gql, useMutation } from '@apollo/client' -import { LnQRSkeleton } from '../components/lnqr' +import { gql, useMutation, useQuery } from '@apollo/client' +import LnQR, { LnQRSkeleton } from '../components/lnqr' import LayoutCenter from '../components/layout-center' import InputGroup from 'react-bootstrap/InputGroup' import { WithdrawlSkeleton } from './withdrawals/[id]' @@ -12,6 +12,7 @@ import { useMe } from '../components/me' import { useEffect, useState } from 'react' import { requestProvider } from 'webln' import { Alert } from 'react-bootstrap' +import { CREATE_WITHDRAWL } from '../fragments/wallet' export default function Wallet () { return ( @@ -49,8 +50,10 @@ export function WalletForm () { if (router.query.type === 'fund') { return - } else { + } else if (router.query.type === 'withdraw') { return + } else { + return } } @@ -125,12 +128,7 @@ export function WithdrawlForm () { const router = useRouter() const me = useMe() - const [createWithdrawl, { called, error }] = useMutation(gql` - mutation createWithdrawl($invoice: String!, $maxFee: Int!) { - createWithdrawl(invoice: $invoice, maxFee: $maxFee) { - id - } - }`) + const [createWithdrawl, { called, error }] = useMutation(CREATE_WITHDRAWL) useEffect(async () => { try { @@ -153,7 +151,6 @@ export function WithdrawlForm () { <>
withdraw
+ or + + + ) } + +function LnQRWith ({ k1, encodedUrl }) { + const router = useRouter() + const query = gql` + { + lnWith(k1: "${k1}") { + withdrawalId + k1 + } + }` + const { data } = useQuery(query, { pollInterval: 1000, fetchPolicy: 'cache-first' }) + + if (data?.lnWith?.withdrawalId) { + router.push(`/withdrawals/${data.lnWith.withdrawalId}`) + } + + return +} + +export function LnWithdrawal () { + // query for challenge + const [createAuth, { data, error }] = useMutation(gql` + mutation createAuth { + createWith { + k1 + encodedUrl + } + }`) + + useEffect(createAuth, []) + + if (error) return
error
+ + if (!data) { + return + } + + return +} diff --git a/prisma/migrations/20211027230800_lnwith/migration.sql b/prisma/migrations/20211027230800_lnwith/migration.sql new file mode 100644 index 00000000..31a2826a --- /dev/null +++ b/prisma/migrations/20211027230800_lnwith/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "LnWith" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "k1" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "withdrawalId" INTEGER NOT NULL, + + PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "LnWith.k1_unique" ON "LnWith"("k1"); diff --git a/prisma/migrations/20211027231327_lnwith_withid_optional/migration.sql b/prisma/migrations/20211027231327_lnwith_withid_optional/migration.sql new file mode 100644 index 00000000..10049891 --- /dev/null +++ b/prisma/migrations/20211027231327_lnwith_withid_optional/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "LnWith" ALTER COLUMN "withdrawalId" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e1b95d75..9195317a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -48,6 +48,15 @@ model LnAuth { pubkey String? } +model LnWith { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at") + k1 String @unique + userId Int + withdrawalId Int? +} + model Invite { id String @id @default(cuid()) createdAt DateTime @default(now()) @map(name: "created_at") diff --git a/styles/globals.scss b/styles/globals.scss index 1a996ede..b1d57e5a 100644 --- a/styles/globals.scss +++ b/styles/globals.scss @@ -5,7 +5,8 @@ $theme-colors: ( "info" : #007cbe, "success" : #5c8001, "twitter" : #1da1f2, - "boost" : #8c25f4 + "boost" : #8c25f4, + "grey" : #e9ecef ); $body-bg: #f5f5f5;