lnurl-withdrawal support
This commit is contained in:
parent
16fe74edf1
commit
b8080137a8
|
@ -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 } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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!
|
||||||
|
}
|
||||||
`
|
`
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
|
@ -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 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} />
|
||||||
|
}
|
||||||
|
|
|
@ -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?
|
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")
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in New Issue