lnurl-withdrawal support

This commit is contained in:
keyan 2021-10-28 14:59:53 -05:00
parent 16fe74edf1
commit b8080137a8
11 changed files with 204 additions and 25 deletions

View File

@ -1,26 +1,52 @@
import { randomBytes } from 'crypto' import { randomBytes } from 'crypto'
import { bech32 } from 'bech32' 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 { export default {
Query: { Query: {
lnAuth: async (parent, { k1 }, { models }) => { lnAuth: async (parent, { k1 }, { models }) => {
return await models.lnAuth.findUnique({ where: { k1 } }) return await models.lnAuth.findUnique({ where: { k1 } })
},
lnWith: async (parent, { k1 }, { models }) => {
return await models.lnWith.findUnique({ where: { k1 } })
} }
}, },
Mutation: { Mutation: {
createAuth: async (parent, args, { models }) => { createAuth: async (parent, args, { models }) => {
const k1 = randomBytes(32).toString('hex') return await models.lnAuth.create({ data: { k1: k1() } })
return await models.lnAuth.create({ data: { 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: { LnAuth: {
encodedUrl: async (lnAuth, args, { models }) => { encodedUrl: async (lnAuth, args, { models }) => {
const url = new URL(process.env.LNAUTH_URL) return encodedUrl(process.env.LNAUTH_URL, 'login', lnAuth.k1)
url.searchParams.set('tag', 'login') }
url.searchParams.set('k1', lnAuth.k1) },
// bech32 encode url LnWith: {
const words = bech32.toWords(Buffer.from(url.toString(), 'utf8')) encodedUrl: async (lnWith, args, { models }) => {
return bech32.encode('lnurl', words, 1023) return encodedUrl(process.env.LNWITH_URL, 'withdrawRequest', lnWith.k1)
},
user: async (lnWith, args, { models }) => {
return await models.user.findUnique({ where: { id: lnWith.userId } })
} }
} }
} }

View File

@ -89,10 +89,6 @@ export default {
} }
}, },
createWithdrawl: async (parent, { invoice, maxFee }, { me, models, lnd }) => { createWithdrawl: async (parent, { invoice, maxFee }, { me, models, lnd }) => {
if (!me) {
throw new AuthenticationError('you must be logged in')
}
// decode invoice to get amount // decode invoice to get amount
let decoded let decoded
try { try {

View File

@ -6,8 +6,9 @@ import resolvers from './resolvers'
import typeDefs from './typeDefs' import typeDefs from './typeDefs'
import models from './models' import models from './models'
import { print } from 'graphql' 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 }) const session = req && await getSession({ req })
return new ApolloClient({ return new ApolloClient({
ssrMode: true, ssrMode: true,
@ -18,7 +19,8 @@ export default async function getSSRApolloClient (req) {
}), }),
context: { context: {
models, models,
me: session ? session.user : null me: session ? session.user : me,
lnd
} }
}), }),
cache: new InMemoryCache() cache: new InMemoryCache()

View File

@ -3,10 +3,12 @@ import { gql } from 'apollo-server-micro'
export default gql` export default gql`
extend type Query { extend type Query {
lnAuth(k1: String!): LnAuth! lnAuth(k1: String!): LnAuth!
lnWith(k1: String!): LnWith!
} }
extend type Mutation { extend type Mutation {
createAuth: LnAuth! createAuth: LnAuth!
createWith: LnWith!
} }
type LnAuth { type LnAuth {
@ -16,4 +18,13 @@ export default gql`
pubkey: String pubkey: String
encodedUrl: String! encodedUrl: String!
} }
type LnWith {
id: ID!
createdAt: String!
k1: String!
user: User!
withdrawalId: Int
encodedUrl: String!
}
` `

View File

@ -23,3 +23,10 @@ export const WITHDRAWL = gql`
status status
} }
}` }`
export const CREATE_WITHDRAWL = gql`
mutation createWithdrawl($invoice: String!, $maxFee: Int!) {
createWithdrawl(invoice: $invoice, maxFee: $maxFee) {
id
}
}`

71
pages/api/lnwith.js Normal file
View File

@ -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' })
}

View File

@ -3,8 +3,8 @@ import { Form, Input, SubmitButton } from '../components/form'
import Link from 'next/link' import Link from 'next/link'
import Button from 'react-bootstrap/Button' import Button from 'react-bootstrap/Button'
import * as Yup from 'yup' import * as Yup from 'yup'
import { gql, useMutation } from '@apollo/client' import { gql, useMutation, useQuery } from '@apollo/client'
import { LnQRSkeleton } from '../components/lnqr' import LnQR, { LnQRSkeleton } from '../components/lnqr'
import LayoutCenter from '../components/layout-center' import LayoutCenter from '../components/layout-center'
import InputGroup from 'react-bootstrap/InputGroup' import InputGroup from 'react-bootstrap/InputGroup'
import { WithdrawlSkeleton } from './withdrawals/[id]' import { WithdrawlSkeleton } from './withdrawals/[id]'
@ -12,6 +12,7 @@ import { useMe } from '../components/me'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { requestProvider } from 'webln' import { requestProvider } from 'webln'
import { Alert } from 'react-bootstrap' import { Alert } from 'react-bootstrap'
import { CREATE_WITHDRAWL } from '../fragments/wallet'
export default function Wallet () { export default function Wallet () {
return ( return (
@ -49,8 +50,10 @@ export function WalletForm () {
if (router.query.type === 'fund') { if (router.query.type === 'fund') {
return <FundForm /> return <FundForm />
} else { } else if (router.query.type === 'withdraw') {
return <WithdrawlForm /> return <WithdrawlForm />
} else {
return <LnWithdrawal />
} }
} }
@ -125,12 +128,7 @@ export function WithdrawlForm () {
const router = useRouter() const router = useRouter()
const me = useMe() const me = useMe()
const [createWithdrawl, { called, error }] = useMutation(gql` const [createWithdrawl, { called, error }] = useMutation(CREATE_WITHDRAWL)
mutation createWithdrawl($invoice: String!, $maxFee: Int!) {
createWithdrawl(invoice: $invoice, maxFee: $maxFee) {
id
}
}`)
useEffect(async () => { useEffect(async () => {
try { try {
@ -153,7 +151,6 @@ export function WithdrawlForm () {
<> <>
<YouHaveSats /> <YouHaveSats />
<Form <Form
className='pt-3'
initial={{ initial={{
invoice: '', invoice: '',
maxFee: MAX_FEE_DEFAULT maxFee: MAX_FEE_DEFAULT
@ -179,6 +176,49 @@ export function WithdrawlForm () {
/> />
<SubmitButton variant='success' className='mt-2'>withdraw</SubmitButton> <SubmitButton variant='success' className='mt-2'>withdraw</SubmitButton>
</Form> </Form>
<span className='my-3 font-weight-bold text-muted'>or</span>
<Link href='/wallet?type=lnurl-withdraw'>
<Button variant='grey'>QR code</Button>
</Link>
</> </>
) )
} }
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 <LnQR value={encodedUrl} status='waiting for you' />
}
export function LnWithdrawal () {
// query for challenge
const [createAuth, { data, error }] = useMutation(gql`
mutation createAuth {
createWith {
k1
encodedUrl
}
}`)
useEffect(createAuth, [])
if (error) return <div>error</div>
if (!data) {
return <LnQRSkeleton status='generating' />
}
return <LnQRWith {...data.createWith} />
}

View File

@ -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");

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LnWith" ALTER COLUMN "withdrawalId" DROP NOT NULL;

View File

@ -48,6 +48,15 @@ model LnAuth {
pubkey String? 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 { model Invite {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at") createdAt DateTime @default(now()) @map(name: "created_at")

View File

@ -5,7 +5,8 @@ $theme-colors: (
"info" : #007cbe, "info" : #007cbe,
"success" : #5c8001, "success" : #5c8001,
"twitter" : #1da1f2, "twitter" : #1da1f2,
"boost" : #8c25f4 "boost" : #8c25f4,
"grey" : #e9ecef
); );
$body-bg: #f5f5f5; $body-bg: #f5f5f5;