From 362f95add906c96927b79b71d153b84f71a9a99d Mon Sep 17 00:00:00 2001 From: SatsAllDay <128755788+SatsAllDay@users.noreply.github.com> Date: Tue, 3 Oct 2023 19:22:56 -0400 Subject: [PATCH] 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 --- api/resolvers/wallet.js | 78 ++++++++++++----------------- api/typeDefs/wallet.js | 2 +- components/form.js | 2 +- fragments/wallet.js | 4 +- lib/lnurl.js | 14 ++++++ lib/validate.js | 32 +++++++++--- pages/wallet.js | 105 +++++++++++++++++++++++++++++++++++----- 7 files changed, 169 insertions(+), 68 deletions(-) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index a2d80d04e..df73f8b85 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -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}`) - } - - const res1 = await req.json() - if (res1.status === 'ERROR') { - throw new Error(res1.reason) + 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 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' } }) - } + const options = await lnAddrOptions(addr) + await ssValidate(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options) - // 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)) { - throw new Error('description hash does not match') + 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 } - if (!decoded.mtokens || BigInt(decoded.mtokens) !== BigInt(milliamount)) { - throw new Error('invoice has incorrect amount') - } // 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) diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index a7d103554..190f3c887 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -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! } diff --git a/components/form.js b/components/form.js index b9580d552..b4bcf2347 100644 --- a/components/form.js +++ b/components/form.js @@ -292,7 +292,7 @@ function InputInner ({ } }} onBlur={(e) => { - field.onBlur(e) + field.onBlur?.(e) onBlur && onBlur(e) }} isInvalid={invalid} diff --git a/fragments/wallet.js b/fragments/wallet.js index 0a5a3d62c..2d79a5873 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -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 } }` diff --git a/lib/lnurl.js b/lib/lnurl.js index f08c28791..f351a13ea 100644 --- a/lib/lnurl.js +++ b/lib/lnurl.js @@ -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 } +} diff --git a/lib/validate.js b/lib/validate.js index 860f3502d..1e4b2f529 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -247,12 +247,32 @@ export const withdrawlSchema = object({ maxFee: intValidator.required('required').min(0, 'must be at least 0') }) -export const lnAddrSchema = object({ - addr: string().email('address is no good').required('required'), - amount: intValidator.required('required').positive('must be positive'), - maxFee: intValidator.required('required').min(0, 'must be at least 0'), - comment: string() -}) +export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) => + object({ + addr: string().email('address is no good').required('required'), + 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: 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() diff --git a/pages/wallet.js b/pages/wallet.js index 671ef23ba..7ea3111b9 100644 --- a/pages/wallet.js +++ b/pages/wallet.js @@ -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 + 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 && }
{ - 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} /> sats} /> sats} /> - comment optional} - name='comment' - hint='only certain lightning addresses can accept comments, and only of a certain length' - /> + {(addrOptions?.commentAllowed || addrOptions?.payerData) && +
+
+ attach
} + body={ + <> + {addrOptions.commentAllowed && + comment optional} + name='comment' + maxLength={addrOptions.commentAllowed} + />} + {addrOptions.payerData?.identifier && + your {me?.name}@stacker.news identifier + {!addrOptions.payerData.identifier.mandatory && + <>{' '}optional} + +} + />} + {addrOptions.payerData?.name && + name{!addrOptions.payerData.name.mandatory && + <>{' '}optional} + +} + />} + {addrOptions.payerData?.email && + + email{!addrOptions.payerData.email.mandatory && + <>{' '}optional} + +} + />} + + } + /> +
+ } send