Add autowithdrawal to lightning address

This commit is contained in:
ekzyis 2024-07-07 09:36:48 +02:00
parent 8dac53d7d5
commit 1ce09051b1
8 changed files with 147 additions and 117 deletions

View File

@ -4,9 +4,8 @@ import crypto, { timingSafeEqual } from 'crypto'
import serialize from './serial' import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { SELECT, itemQueryWithMeta } from './item' import { SELECT, itemQueryWithMeta } from './item'
import { lnAddrOptions } from '@/lib/lnurl'
import { msatsToSats, msatsToSatsDecimal, ensureB64 } from '@/lib/format' import { msatsToSats, msatsToSatsDecimal, ensureB64 } from '@/lib/format'
import { CLNAutowithdrawSchema, amountSchema, lnAddrAutowithdrawSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '@/lib/validate' import { CLNAutowithdrawSchema, amountSchema, ssValidate, withdrawlSchema } from '@/lib/validate'
import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT, Wallet } from '@/lib/constants' import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT, Wallet } from '@/lib/constants'
import { datePivot } from '@/lib/time' import { datePivot } from '@/lib/time'
import assertGofacYourself from './ofac' import assertGofacYourself from './ofac'
@ -15,8 +14,10 @@ import { createInvoice as createInvoiceCLN } from '@/lib/cln'
import { bolt11Tags } from '@/lib/bolt11' import { bolt11Tags } from '@/lib/bolt11'
import { checkInvoice } from 'worker/wallet' import { checkInvoice } from 'worker/wallet'
import * as lnd from '@/components/wallet/lnd' import * as lnd from '@/components/wallet/lnd'
import * as lnAddr from '@/components/wallet/lightning-address'
import { fetchLnAddrInvoice } from '@/lib/wallet'
export const SERVER_WALLET_DEFS = [lnd] export const SERVER_WALLET_DEFS = [lnd, lnAddr]
function walletResolvers () { function walletResolvers () {
const resolvers = {} const resolvers = {}
@ -481,20 +482,6 @@ export default {
}, },
{ settings, data }, { me, models }) { settings, data }, { me, models })
}, },
upsertWalletLNAddr: async (parent, { settings, ...data }, { me, models }) => {
const wallet = Wallet.LnAddr
return await upsertWallet(
{
schema: lnAddrAutowithdrawSchema,
wallet,
testConnect: async ({ address }) => {
const options = await lnAddrOptions(address)
await addWalletLog({ wallet, level: 'SUCCESS', message: 'fetched payment details' }, { me, models })
return options
}
},
{ settings, data }, { me, models })
},
removeWallet: async (parent, { id }, { me, models }) => { removeWallet: async (parent, { id }, { me, models }) => {
if (!me) { if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
@ -746,64 +733,20 @@ export async function createWithdrawal (parent, { invoice, maxFee }, { me, model
} }
export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer }, export async function sendToLnAddr (parent, { addr, amount, maxFee, comment, ...payer },
{ me, models, lnd, headers, walletId }) { { me, models, lnd, headers }) {
if (!me) { if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
} }
assertApiKeyNotPermitted({ me }) assertApiKeyNotPermitted({ me })
const options = await lnAddrOptions(addr) const res = await fetchLnAddrInvoice({ addr, amount, maxFee, comment, ...payer },
await ssValidate(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options) {
me,
if (payer) { models,
payer = { lnd,
...payer, lnService: { decodePaymentRequest, getIdentity }
identifier: payer.identifier ? `${me.name}@stacker.news` : undefined
}
payer = Object.fromEntries(
Object.entries(payer).filter(([, value]) => !!value)
)
}
const milliamount = 1000 * amount
const callback = new URL(options.callback)
callback.searchParams.append('amount', milliamount)
if (comment?.length) {
callback.searchParams.append('comment', comment)
}
let stringifiedPayerData = ''
if (payer && Object.entries(payer).length) {
stringifiedPayerData = JSON.stringify(payer)
callback.searchParams.append('payerdata', stringifiedPayerData)
}
// call callback with amount and conditionally comment
const res = await (await fetch(callback.toString())).json()
if (res.status === 'ERROR') {
throw new Error(res.reason)
}
// decode invoice
try {
const decoded = await decodePaymentRequest({ lnd, request: res.pr })
const ourPubkey = (await getIdentity({ lnd })).public_key
if (walletId && decoded.destination === ourPubkey && process.env.NODE_ENV === 'production') {
// unset lnaddr so we don't trigger another withdrawal with same destination
await models.wallet.deleteMany({
where: { userId: me.id, type: Wallet.LnAddr.type }
}) })
throw new Error('automated withdrawals to other stackers are not allowed')
}
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 // take pr and createWithdrawl
return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers, walletId }) return await createWithdrawal(parent, { invoice: res.pr, maxFee }, { me, models, lnd, headers })
} }

View File

@ -39,7 +39,6 @@ export default gql`
dropBolt11(id: ID): Withdrawl dropBolt11(id: ID): Withdrawl
${walletTypeDefs()} ${walletTypeDefs()}
upsertWalletCLN(id: ID, socket: String!, rune: String!, cert: String, settings: AutowithdrawSettings!): Boolean upsertWalletCLN(id: ID, socket: String!, rune: String!, cert: String, settings: AutowithdrawSettings!): Boolean
upsertWalletLNAddr(id: ID, address: String!, settings: AutowithdrawSettings!): Boolean
removeWallet(id: ID!): Boolean removeWallet(id: ID!): Boolean
deleteWalletLogs(wallet: String): Boolean deleteWalletLogs(wallet: String): Boolean
} }

View File

@ -9,12 +9,13 @@ import * as lnbits from '@/components/wallet/lnbits'
import * as nwc from '@/components/wallet/nwc' import * as nwc from '@/components/wallet/nwc'
import * as lnc from '@/components/wallet/lnc' import * as lnc from '@/components/wallet/lnc'
import * as lnd from '@/components/wallet/lnd' import * as lnd from '@/components/wallet/lnd'
import * as lnAddr from '@/components/wallet/lightning-address'
import { gql, useApolloClient, useQuery } from '@apollo/client' import { gql, useApolloClient, useQuery } from '@apollo/client'
import { REMOVE_WALLET, WALLET_BY_TYPE } from '@/fragments/wallet' import { REMOVE_WALLET, WALLET_BY_TYPE } from '@/fragments/wallet'
import { autowithdrawInitial } from '../autowithdraw-shared' import { autowithdrawInitial } from '../autowithdraw-shared'
// wallet definitions // wallet definitions
export const WALLET_DEFS = [lnbits, nwc, lnc, lnd] export const WALLET_DEFS = [lnbits, nwc, lnc, lnd, lnAddr]
export const Status = { export const Status = {
Initialized: 'Initialized', Initialized: 'Initialized',

View File

@ -0,0 +1,51 @@
import { lnAddrOptions } from '@/lib/lnurl'
import { lnAddrAutowithdrawSchema } from '@/lib/validate'
import { fetchLnAddrInvoice } from '@/lib/wallet'
export const name = 'lightning-address'
export const fields = [
{
name: 'address',
label: 'lightning address',
type: 'text',
hint: 'tor or clearnet',
autoComplete: 'off'
}
]
export const card = {
title: 'lightning address',
subtitle: 'autowithdraw to a lightning address',
badges: ['receive only', 'non-custodialish']
}
export const schema = lnAddrAutowithdrawSchema
export const server = {
walletType: 'LIGHTNING_ADDRESS',
walletField: 'walletLightningAddress',
resolverName: 'upsertWalletLNAddr',
testConnect: async (
{ address },
{ me, models, addWalletLog }
) => {
const options = await lnAddrOptions(address)
await addWalletLog({ wallet: { type: 'LIGHTNING_ADDRESS' }, level: 'SUCCESS', message: 'fetched payment details' }, { me, models })
return options
},
createInvoice: async (
{ amount, maxFee },
{ address },
{ me, models, lnd, lnService }
) => {
const res = await fetchLnAddrInvoice({ addr: address, amount, maxFee }, {
me,
models,
lnd,
lnService,
autoWithdraw: true
})
return res.pr
}
}

View File

@ -87,7 +87,7 @@ export const server = {
} }
}, },
createInvoice: async ( createInvoice: async (
amount, { amount },
{ cert, macaroon, socket }, { cert, macaroon, socket },
{ me, lnService: { authenticatedLndGrpc, createInvoice } } { me, lnService: { authenticatedLndGrpc, createInvoice } }
) => { ) => {

View File

@ -100,13 +100,6 @@ export const SEND_TO_LNADDR = gql`
} }
}` }`
export const UPSERT_WALLET_LNADDR =
gql`
mutation upsertWalletLNAddr($id: ID, $address: String!, $settings: AutowithdrawSettings!) {
upsertWalletLNAddr(id: $id, address: $address, settings: $settings)
}
`
export const UPSERT_WALLET_CLN = export const UPSERT_WALLET_CLN =
gql` gql`
mutation upsertWalletCLN($id: ID, $socket: String!, $rune: String!, $cert: String, $settings: AutowithdrawSettings!) { mutation upsertWalletCLN($id: ID, $socket: String!, $rune: String!, $cert: String, $settings: AutowithdrawSettings!) {

63
lib/wallet.js Normal file
View File

@ -0,0 +1,63 @@
import { lnAddrOptions } from './lnurl'
import { lnAddrSchema, ssValidate } from './validate'
export async function fetchLnAddrInvoice ({ addr, amount, maxFee, comment, ...payer },
{
me, models, lnd, autoWithdraw = false,
lnService: { decodePaymentRequest, getIdentity }
}) {
const options = await lnAddrOptions(addr)
await ssValidate(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options)
if (payer) {
payer = {
...payer,
identifier: payer.identifier ? `${me.name}@stacker.news` : undefined
}
payer = Object.fromEntries(
Object.entries(payer).filter(([, value]) => !!value)
)
}
const milliamount = 1000 * amount
const callback = new URL(options.callback)
callback.searchParams.append('amount', milliamount)
if (comment?.length) {
callback.searchParams.append('comment', comment)
}
let stringifiedPayerData = ''
if (payer && Object.entries(payer).length) {
stringifiedPayerData = JSON.stringify(payer)
callback.searchParams.append('payerdata', stringifiedPayerData)
}
// call callback with amount and conditionally comment
const res = await (await fetch(callback.toString())).json()
if (res.status === 'ERROR') {
throw new Error(res.reason)
}
// decode invoice
try {
const decoded = await decodePaymentRequest({ lnd, request: res.pr })
const ourPubkey = (await getIdentity({ lnd })).public_key
if (autoWithdraw && decoded.destination === ourPubkey && process.env.NODE_ENV === 'production') {
// unset lnaddr so we don't trigger another withdrawal with same destination
await models.wallet.deleteMany({
// TODO: replace hardcoded 'LIGHTNING_ADDRESS' with wallet.type
where: { userId: me.id, type: 'LIGHTNING_ADDRESS' }
})
throw new Error('automated withdrawals to other stackers are not allowed')
}
if (!decoded.mtokens || BigInt(decoded.mtokens) !== BigInt(milliamount)) {
throw new Error('invoice has incorrect amount')
}
} catch (e) {
console.log(e)
throw e
}
return res
}

View File

@ -1,4 +1,4 @@
import { authenticatedLndGrpc, createInvoice as lndCreateInvoice } from 'ln-service' import { authenticatedLndGrpc, createInvoice as lndCreateInvoice, getIdentity, decodePaymentRequest } from 'ln-service'
import { msatsToSats, satsToMsats } from '@/lib/format' import { msatsToSats, satsToMsats } from '@/lib/format'
// import { datePivot } from '@/lib/time' // import { datePivot } from '@/lib/time'
import { createWithdrawal, /* sendToLnAddr, */ addWalletLog, SERVER_WALLET_DEFS } from '@/api/resolvers/wallet' import { createWithdrawal, /* sendToLnAddr, */ addWalletLog, SERVER_WALLET_DEFS } from '@/api/resolvers/wallet'
@ -61,20 +61,12 @@ export async function autoWithdraw ({ data: { id }, models, lnd }) {
} }
} }
// TODO: implement CLN and LnAddr wallets // TODO: implement CLN autowithdrawal
// ------ // ------
// if (wallet.type === Wallet.LND.type) { // if (wallet.type === Wallet.CLN.type) {
// await autowithdrawLND(
// { amount, maxFee },
// { models, me: user, lnd })
// } else if (wallet.type === Wallet.CLN.type) {
// await autowithdrawCLN( // await autowithdrawCLN(
// { amount, maxFee }, // { amount, maxFee },
// { models, me: user, lnd }) // { models, me: user, lnd })
// } else if (wallet.type === Wallet.LnAddr.type) {
// await autowithdrawLNAddr(
// { amount, maxFee },
// { models, me: user, lnd })
// } // }
return return
@ -115,36 +107,24 @@ async function autowithdraw (
throw new Error(`no ${walletType} wallet found`) throw new Error(`no ${walletType} wallet found`)
} }
const bolt11 = await walletCreateInvoice(amount, wallet[walletField], { me, lnService: { authenticatedLndGrpc, createInvoice: lndCreateInvoice } }) const bolt11 = await walletCreateInvoice(
{ amount, maxFee },
wallet[walletField],
{
me,
models,
lnd,
lnService: {
authenticatedLndGrpc,
createInvoice: lndCreateInvoice,
getIdentity,
decodePaymentRequest
}
})
return await createWithdrawal(null, { invoice: bolt11, maxFee }, { me, models, lnd, walletId: wallet.id }) return await createWithdrawal(null, { invoice: bolt11, maxFee }, { me, models, lnd, walletId: wallet.id })
} }
// async function autowithdrawLNAddr (
// { amount, maxFee },
// { me, models, lnd, headers, autoWithdraw = false }) {
// if (!me) {
// throw new Error('me not specified')
// }
//
// const wallet = await models.wallet.findFirst({
// where: {
// userId: me.id,
// type: Wallet.LnAddr.type
// },
// include: {
// walletLightningAddress: true
// }
// })
//
// if (!wallet || !wallet.walletLightningAddress) {
// throw new Error('no lightning address wallet found')
// }
//
// const { walletLightningAddress: { address } } = wallet
// return await sendToLnAddr(null, { addr: address, amount, maxFee }, { me, models, lnd, walletId: wallet.id })
// }
// async function autowithdrawCLN ({ amount, maxFee }, { me, models, lnd }) { // async function autowithdrawCLN ({ amount, maxFee }, { me, models, lnd }) {
// if (!me) { // if (!me) {
// throw new Error('me not specified') // throw new Error('me not specified')