LUD-18 Wallet implementation (#531)

* LUD-18 Wallet implementation

Query the lightning address provider client-side to learn of capabilities

Conditionally render LUD-12 and LUD-18 fields based on what the remote
server says is supported

Allow identifier, name, and email to be sent from the SN side during the withdrawal flow. Auth seems too complicated for our use case, and idk about pubkey?

* Clear inputs if the new ln addr provier doesn't support those fields

* various ux improvements

* dynamic client-side validation for required payer data

* don't re-init form state on error

* correct min and max amount values

* only send applicable data to graphql api based on payerdata schema

* input type for numeric values (amount, max fee)

* update step for amount and max fee

* Fix identifier optional and field blur

* reuse more code

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
This commit is contained in:
SatsAllDay 2023-10-03 19:22:56 -04:00 committed by GitHub
parent 3acaee377b
commit 362f95add9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 169 additions and 68 deletions

View File

@ -5,7 +5,7 @@ import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import lnpr from 'bolt11'
import { SELECT } from './item'
import { lnurlPayDescriptionHash } from '../../lib/lnurl'
import { lnAddrOptions, lnurlPayDescriptionHash } from '../../lib/lnurl'
import { msatsToSats, msatsToSatsDecimal } from '../../lib/format'
import { amountSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '../../lib/validate'
import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, ANON_USER_ID, BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT } from '../../lib/constants'
@ -279,71 +279,57 @@ export default {
}
},
createWithdrawl: createWithdrawal,
sendToLnAddr: async (parent, { addr, amount, maxFee, comment }, { me, models, lnd }) => {
await ssValidate(lnAddrSchema, { addr, amount, maxFee, comment })
const [name, domain] = addr.split('@')
let req
try {
req = await fetch(`https://${domain}/.well-known/lnurlp/${name}`)
} catch (e) {
throw new Error(`error initiating protocol with https://${domain}`)
sendToLnAddr: async (parent, { addr, amount, maxFee, comment, ...payer }, { me, models, lnd }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
const res1 = await req.json()
if (res1.status === 'ERROR') {
throw new Error(res1.reason)
}
const options = await lnAddrOptions(addr)
await ssValidate(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
const milliamount = amount * 1000
// check that amount is within min and max sendable
if (milliamount < res1.minSendable || milliamount > res1.maxSendable) {
throw new GraphQLError(`amount must be >= ${res1.minSendable / 1000} and <= ${res1.maxSendable / 1000}`, { extensions: { code: 'BAD_INPUT' } })
}
// if a comment is provided by the user
if (comment?.length) {
// if the receiving address doesn't accept comments, reject the request and tell the user why
if (res1.commentAllowed === undefined) {
throw new GraphQLError('comments are not accepted by this lightning address provider', { extensions: { code: 'BAD_INPUT' } })
}
// if the receiving address accepts comments, verify the max length isn't violated
if (res1.commentAllowed && Number(res1.commentAllowed) && comment.length > Number(res1.commentAllowed)) {
throw new GraphQLError(`comments sent to this lightning address provider must not exceed ${res1.commentAllowed} characters in length`, { extensions: { code: 'BAD_INPUT' } })
if (payer) {
payer = {
...payer,
identifier: payer.identifier ? me.name : undefined
}
}
const callback = new URL(res1.callback)
const milliamount = 1000 * amount
const callback = new URL(options.callback)
callback.searchParams.append('amount', milliamount)
if (comment?.length) {
callback.searchParams.append('comment', comment)
}
let encodedPayerData = ''
if (payer) {
encodedPayerData = encodeURIComponent(JSON.stringify(payer))
callback.searchParams.append('payerdata', encodedPayerData)
}
// call callback with amount and conditionally comment
const res2 = await (await fetch(callback.toString())).json()
if (res2.status === 'ERROR') {
throw new Error(res2.reason)
const res = await (await fetch(callback.toString())).json()
if (res.status === 'ERROR') {
throw new Error(res.reason)
}
// decode invoice
let decoded
try {
decoded = await decodePaymentRequest({ lnd, request: res2.pr })
} catch (error) {
console.log(error)
throw new Error('could not decode invoice')
}
if (decoded.description_hash !== lnurlPayDescriptionHash(res1.metadata)) {
const decoded = await decodePaymentRequest({ lnd, request: res.pr })
if (decoded.description_hash !== lnurlPayDescriptionHash(`${options.metadata}${encodedPayerData}`)) {
throw new Error('description hash does not match')
}
if (!decoded.mtokens || BigInt(decoded.mtokens) !== BigInt(milliamount)) {
throw new Error('invoice has incorrect amount')
}
} catch (e) {
console.log(e)
throw e
}
// take pr and createWithdrawl
return await createWithdrawal(parent, { invoice: res2.pr, maxFee }, { me, models, lnd })
return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd })
},
cancelInvoice: async (parent, { hash, hmac }, { models, lnd }) => {
const hmac2 = createHmac(hash)

View File

@ -11,7 +11,7 @@ export default gql`
extend type Mutation {
createInvoice(amount: Int!, expireSecs: Int, hodlInvoice: Boolean): Invoice!
createWithdrawl(invoice: String!, maxFee: Int!): Withdrawl!
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String): Withdrawl!
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl!
cancelInvoice(hash: String!, hmac: String!): Invoice!
}

View File

@ -292,7 +292,7 @@ function InputInner ({
}
}}
onBlur={(e) => {
field.onBlur(e)
field.onBlur?.(e)
onBlur && onBlur(e)
}}
isInvalid={invalid}

View File

@ -65,8 +65,8 @@ export const CREATE_WITHDRAWL = gql`
}`
export const SEND_TO_LNADDR = gql`
mutation sendToLnAddr($addr: String!, $amount: Int!, $maxFee: Int!, $comment: String) {
sendToLnAddr(addr: $addr, amount: $amount, maxFee: $maxFee, comment: $comment) {
mutation sendToLnAddr($addr: String!, $amount: Int!, $maxFee: Int!, $comment: String, $identifier: Boolean, $name: String, $email: String) {
sendToLnAddr(addr: $addr, amount: $amount, maxFee: $maxFee, comment: $comment, identifier: $identifier, name: $name, email: $email) {
id
}
}`

View File

@ -1,5 +1,6 @@
import { createHash } from 'crypto'
import { bech32 } from 'bech32'
import { lnAddrSchema } from './validate'
export function encodeLNUrl (url) {
const words = bech32.toWords(Buffer.from(url.toString(), 'utf8'))
@ -23,3 +24,16 @@ export function lnurlPayDescriptionHashForUser (username) {
export function lnurlPayDescriptionHash (data) {
return createHash('sha256').update(data).digest('hex')
}
export async function lnAddrOptions (addr) {
await lnAddrSchema().fields.addr.validate(addr)
const [name, domain] = addr.split('@')
const req = await fetch(`https://${domain}/.well-known/lnurlp/${name}`)
const res = await req.json()
if (res.status === 'ERROR') {
throw new Error(res.reason)
}
const { minSendable, maxSendable, ...leftOver } = res
return { min: minSendable / 1000, max: maxSendable / 1000, ...leftOver }
}

View File

@ -247,12 +247,32 @@ export const withdrawlSchema = object({
maxFee: intValidator.required('required').min(0, 'must be at least 0')
})
export const lnAddrSchema = object({
export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) =>
object({
addr: string().email('address is no good').required('required'),
amount: intValidator.required('required').positive('must be positive'),
amount: (() => {
const schema = intValidator.required('required').positive('must be positive').min(
min || 1, `must be at least ${min || 1}`)
return max ? schema.max(max, `must be at most ${max}`) : schema
})(),
maxFee: intValidator.required('required').min(0, 'must be at least 0'),
comment: string()
})
comment: commentAllowed
? string().max(commentAllowed, `must be less than ${commentAllowed}`)
: string()
}).concat(object().shape(Object.keys(payerData || {}).reduce((accum, key) => {
const entry = payerData[key]
if (key === 'email') {
accum[key] = string().email()
} else if (key === 'identifier') {
accum[key] = boolean()
} else {
accum[key] = string()
}
if (entry?.mandatory) {
accum[key] = accum[key].required()
}
return accum
}, {})))
export const bioSchema = object({
bio: string().required('required').trim()

View File

@ -1,5 +1,5 @@
import { useRouter } from 'next/router'
import { Form, Input, SubmitButton } from '../components/form'
import { Checkbox, Form, Input, SubmitButton } from '../components/form'
import Link from 'next/link'
import Button from 'react-bootstrap/Button'
import { gql, useMutation, useQuery } from '@apollo/client'
@ -19,6 +19,8 @@ import { SSR } from '../lib/constants'
import { numWithUnits } from '../lib/format'
import styles from '../components/user-header.module.css'
import HiddenWalletSummary from '../components/hidden-wallet-summary'
import AccordianItem from '../components/accordian-item'
import { lnAddrOptions } from '../lib/lnurl'
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
@ -291,26 +293,52 @@ export function LnWithdrawal () {
}
export function LnAddrWithdrawal () {
const me = useMe()
const router = useRouter()
const [sendToLnAddr, { called, error }] = useMutation(SEND_TO_LNADDR)
const defaultOptions = { min: 1 }
const [addrOptions, setAddrOptions] = useState(defaultOptions)
const [formSchema, setFormSchema] = useState(lnAddrSchema())
if (called && !error) {
return <WithdrawlSkeleton status='sending' />
const onAddrChange = async (formik, e) => {
let options
try {
options = await lnAddrOptions(e.target.value)
} catch (e) {
console.log(e)
setAddrOptions(defaultOptions)
return
}
setAddrOptions(options)
setFormSchema(lnAddrSchema(options))
}
return (
<>
{called && !error && <WithdrawlSkeleton status='sending' />}
<Form
// hide/show instead of add/remove from react tree to avoid re-initializing the form state on error
style={{ display: !(called && !error) ? 'block' : 'none' }}
initial={{
addr: '',
amount: 1,
maxFee: 10,
comment: ''
comment: '',
identifier: false,
name: '',
email: ''
}}
schema={lnAddrSchema}
schema={formSchema}
initialError={error ? error.toString() : undefined}
onSubmit={async ({ addr, amount, maxFee, comment }) => {
const { data } = await sendToLnAddr({ variables: { addr, amount: Number(amount), maxFee: Number(maxFee), comment } })
onSubmit={async ({ amount, maxFee, ...values }) => {
const { data } = await sendToLnAddr({
variables: {
amount: Number(amount),
maxFee: Number(maxFee),
...values
}
})
router.push(`/withdrawals/${data.sendToLnAddr.id}`)
}}
>
@ -319,24 +347,77 @@ export function LnAddrWithdrawal () {
name='addr'
required
autoFocus
onChange={onAddrChange}
/>
<Input
label='amount'
name='amount'
type='number'
step={10}
required
min={addrOptions.min}
max={addrOptions.max}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<Input
label='max fee'
name='maxFee'
type='number'
step={10}
required
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
{(addrOptions?.commentAllowed || addrOptions?.payerData) &&
<div className='my-3 border border-3 rounded'>
<div className='p-3'>
<AccordianItem
show
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>attach</div>}
body={
<>
{addrOptions.commentAllowed &&
<Input
label={<>comment <small className='text-muted ms-2'>optional</small></>}
name='comment'
hint='only certain lightning addresses can accept comments, and only of a certain length'
maxLength={addrOptions.commentAllowed}
/>}
{addrOptions.payerData?.identifier &&
<Checkbox
name='identifier'
required={addrOptions.payerData.identifier.mandatory}
label={
<>your {me?.name}@stacker.news identifier
{!addrOptions.payerData.identifier.mandatory &&
<>{' '}<small className='text-muted ms-2'>optional</small></>}
</>
}
/>}
{addrOptions.payerData?.name &&
<Input
name='name'
required={addrOptions.payerData.name.mandatory}
label={
<>name{!addrOptions.payerData.name.mandatory &&
<>{' '}<small className='text-muted ms-2'>optional</small></>}
</>
}
/>}
{addrOptions.payerData?.email &&
<Input
name='email'
required={addrOptions.payerData.email.mandatory}
label={
<>
email{!addrOptions.payerData.email.mandatory &&
<>{' '}<small className='text-muted ms-2'>optional</small></>}
</>
}
/>}
</>
}
/>
</div>
</div>}
<SubmitButton variant='success' className='mt-2'>send</SubmitButton>
</Form>
</>