stacker.news/components/webln/lnc.js

216 lines
7.2 KiB
JavaScript

import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useWalletLogger } from '../logger'
import LNC from '@lightninglabs/lnc-web'
import { Status, migrateLocalStorage } from '.'
import { bolt11Tags } from '@/lib/bolt11'
import useModal from '../modal'
import { Form, PasswordInput, SubmitButton } from '../form'
import CancelButton from '../cancel-button'
import { Mutex } from 'async-mutex'
import { Wallet } from '@/lib/constants'
import { useMe } from '../me'
import { InvoiceCanceledError, InvoiceExpiredError } from '@/components/payment'
const LNCContext = createContext()
const mutex = new Mutex()
async function getLNC ({ me }) {
if (window.lnc) return window.lnc
// backwards compatibility: migrate to new storage key
if (me) migrateLocalStorage('lnc-web:default', `lnc-web:stacker:${me.id}`)
window.lnc = new LNC({ namespace: me?.id ? `stacker:${me.id}` : undefined })
return window.lnc
}
// default password if the user hasn't set one
export const XXX_DEFAULT_PASSWORD = 'password'
function validateNarrowPerms (lnc) {
if (!lnc.hasPerms('lnrpc.Lightning.SendPaymentSync')) {
throw new Error('missing permission: lnrpc.Lightning.SendPaymentSync')
}
if (lnc.hasPerms('lnrpc.Lightning.SendCoins')) {
throw new Error('too broad permission: lnrpc.Wallet.SendCoins')
}
// TODO: need to check for more narrow permissions
// blocked by https://github.com/lightninglabs/lnc-web/issues/112
}
export function LNCProvider ({ children }) {
const me = useMe()
const { logger } = useWalletLogger(Wallet.LNC)
const [config, setConfig] = useState({})
const [lnc, setLNC] = useState()
const [status, setStatus] = useState()
const [modal, showModal] = useModal()
const getInfo = useCallback(async () => {
logger.info('getInfo called')
return await lnc.lightning.getInfo()
}, [logger, lnc])
const unlock = useCallback(async (connect) => {
if (status === Status.Enabled) return config.password
return await new Promise((resolve, reject) => {
const cancelAndReject = async () => {
reject(new Error('password canceled'))
}
showModal(onClose => {
return (
<Form
initial={{
password: ''
}}
onSubmit={async (values) => {
try {
lnc.credentials.password = values?.password
setStatus(Status.Enabled)
setConfig({ pairingPhrase: lnc.credentials.pairingPhrase, password: values.password })
logger.ok('wallet enabled')
onClose()
resolve(values.password)
} catch (err) {
logger.error('failed attempt to unlock wallet', err)
throw err
}
}}
>
<h4 className='text-center mb-3'>Unlock LNC</h4>
<PasswordInput
label='password'
name='password'
/>
<div className='mt-5 d-flex justify-content-between'>
<CancelButton onClick={() => { onClose(); cancelAndReject() }} />
<SubmitButton variant='primary'>unlock</SubmitButton>
</div>
</Form>
)
}, { onClose: cancelAndReject })
})
}, [logger, showModal, setConfig, lnc, status])
const sendPayment = useCallback(async (bolt11) => {
const hash = bolt11Tags(bolt11).payment_hash
logger.info('sending payment:', `payment_hash=${hash}`)
return await mutex.runExclusive(async () => {
try {
const password = await unlock()
// credentials need to be decrypted before connecting after a disconnect
lnc.credentials.password = password || XXX_DEFAULT_PASSWORD
await lnc.connect()
const { paymentError, paymentPreimage: preimage } =
await lnc.lnd.lightning.sendPaymentSync({ payment_request: bolt11 })
if (paymentError) throw new Error(paymentError)
if (!preimage) throw new Error('No preimage in response')
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
return { preimage }
} catch (err) {
const msg = err.message || err.toString?.()
logger.error('payment failed:', `payment_hash=${hash}`, msg)
if (msg.includes('invoice expired')) {
throw new InvoiceExpiredError(hash)
}
if (msg.includes('canceled')) {
throw new InvoiceCanceledError(hash)
}
throw err
} finally {
try {
lnc.disconnect()
logger.info('disconnecting after:', `payment_hash=${hash}`)
// wait for lnc to disconnect before releasing the mutex
await new Promise((resolve, reject) => {
let counter = 0
const interval = setInterval(() => {
if (lnc.isConnected) {
if (counter++ > 100) {
logger.error('failed to disconnect from lnc')
clearInterval(interval)
reject(new Error('failed to disconnect from lnc'))
}
return
}
clearInterval(interval)
resolve()
})
}, 50)
} catch (err) {
logger.error('failed to disconnect from lnc', err)
}
}
})
}, [logger, lnc, unlock])
const saveConfig = useCallback(async config => {
setConfig(config)
try {
lnc.credentials.pairingPhrase = config.pairingPhrase
await lnc.connect()
await validateNarrowPerms(lnc)
lnc.credentials.password = config?.password || XXX_DEFAULT_PASSWORD
setStatus(Status.Enabled)
logger.ok('wallet enabled')
} catch (err) {
logger.error('invalid config:', err)
setStatus(Status.Error)
logger.info('wallet disabled')
throw err
} finally {
lnc.disconnect()
}
}, [logger, lnc])
const clearConfig = useCallback(async () => {
await lnc.credentials.clear(false)
if (lnc.isConnected) lnc.disconnect()
setStatus(undefined)
setConfig({})
logger.info('cleared config')
}, [logger, lnc])
useEffect(() => {
(async () => {
try {
const lnc = await getLNC({ me })
setLNC(lnc)
setStatus(Status.Initialized)
if (lnc.credentials.isPaired) {
try {
// try the default password
lnc.credentials.password = XXX_DEFAULT_PASSWORD
} catch (err) {
setStatus(Status.Locked)
logger.info('wallet needs password before enabling')
return
}
setStatus(Status.Enabled)
setConfig({ pairingPhrase: lnc.credentials.pairingPhrase, password: lnc.credentials.password })
}
} catch (err) {
logger.error('wallet could not be loaded:', err)
setStatus(Status.Error)
}
})()
}, [me, setStatus, setConfig, logger])
const value = useMemo(
() => ({ name: 'lnc', status, unlock, getInfo, sendPayment, config, saveConfig, clearConfig }),
[status, unlock, getInfo, sendPayment, config, saveConfig, clearConfig])
return (
<LNCContext.Provider value={value}>
{children}
{modal}
</LNCContext.Provider>
)
}
export function useLNC () {
return useContext(LNCContext)
}