lnurl-withdrawal support
This commit is contained in:
parent
16fe74edf1
commit
b8080137a8
|
@ -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 } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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!
|
||||
}
|
||||
`
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}`
|
||||
|
|
|
@ -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' })
|
||||
}
|
|
@ -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 <FundForm />
|
||||
} else {
|
||||
} else if (router.query.type === 'withdraw') {
|
||||
return <WithdrawlForm />
|
||||
} else {
|
||||
return <LnWithdrawal />
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 () {
|
|||
<>
|
||||
<YouHaveSats />
|
||||
<Form
|
||||
className='pt-3'
|
||||
initial={{
|
||||
invoice: '',
|
||||
maxFee: MAX_FEE_DEFAULT
|
||||
|
@ -179,6 +176,49 @@ export function WithdrawlForm () {
|
|||
/>
|
||||
<SubmitButton variant='success' className='mt-2'>withdraw</SubmitButton>
|
||||
</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} />
|
||||
}
|
||||
|
|
|
@ -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");
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "LnWith" ALTER COLUMN "withdrawalId" DROP NOT NULL;
|
|
@ -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")
|
||||
|
|
|
@ -5,7 +5,8 @@ $theme-colors: (
|
|||
"info" : #007cbe,
|
||||
"success" : #5c8001,
|
||||
"twitter" : #1da1f2,
|
||||
"boost" : #8c25f4
|
||||
"boost" : #8c25f4,
|
||||
"grey" : #e9ecef
|
||||
);
|
||||
|
||||
$body-bg: #f5f5f5;
|
||||
|
|
Loading…
Reference in New Issue