add lnc attached wallet (#1104)

* add litd to docker env

* lnc payments

* handle locked wallet configuration

* create new lnc connection for every action

* ensure creds are decrypted before reconnecting

* perform permissions check
This commit is contained in:
Keyan 2024-04-26 21:22:30 -05:00 committed by GitHub
parent 2340df3d8f
commit c3d709b025
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 2575 additions and 100 deletions

View File

@ -3,7 +3,6 @@ import { GraphQLError } from 'graphql'
import crypto from 'crypto'
import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import lnpr from 'bolt11'
import { SELECT } from './item'
import { lnAddrOptions } from '@/lib/lnurl'
import { msatsToSats, msatsToSatsDecimal, ensureB64 } from '@/lib/format'
@ -13,6 +12,7 @@ import { datePivot } from '@/lib/time'
import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey'
import { createInvoice as createInvoiceCLN } from '@/lib/cln'
import { bolt11Tags } from '@/lib/bolt11'
export async function getInvoice (parent, { id }, { me, models, lnd }) {
const inv = await models.invoice.findUnique({
@ -282,17 +282,7 @@ export default {
f = { ...f, ...f.other }
if (f.bolt11) {
const inv = lnpr.decode(f.bolt11)
if (inv) {
const { tags } = inv
for (const tag of tags) {
if (tag.tagName === 'description') {
// prioritize description from bolt11 over description from our DB
f.description = tag.data
break
}
}
}
f.description = bolt11Tags(f.bolt11).description
}
switch (f.type) {

View File

@ -1,11 +1,11 @@
import { decode } from 'bolt11'
import AccordianItem from './accordian-item'
import { CopyInput } from './form'
import { bolt11Tags } from '@/lib/bolt11'
export default ({ bolt11, preimage, children }) => {
let description, paymentHash
if (bolt11) {
({ tagsObject: { description, payment_hash: paymentHash } } = decode(bolt11))
({ description, payment_hash: paymentHash } = bolt11Tags(bolt11))
}
return (

View File

@ -1047,7 +1047,7 @@ function Client (Component) {
const [,, helpers] = useField(props)
useEffect(() => {
helpers.setValue(initialValue)
initialValue && helpers.setValue(initialValue)
}, [initialValue])
return <Component {...props} />

View File

@ -241,7 +241,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
const fragment = {
id: `Item:${itemId}`,
fragment: gql`
fragment ItemMeSats on Item {
fragment ItemMeSatsInvoice on Item {
sats
meSats
}
@ -308,7 +308,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
const INVOICE_CANCELED_ERROR = 'invoice canceled'
const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, gqlCacheUpdate, flowId }) => {
if (provider.enabled) {
if (provider) {
try {
return await waitForWebLNPayment({ provider, invoice, pollInvoice, gqlCacheUpdate, flowId })
} catch (err) {

View File

@ -100,7 +100,7 @@ export default function ItemAct ({ onClose, itemId, down, children }) {
const fragment = {
id: `Item:${itemId}`,
fragment: gql`
fragment ItemMeSats on Item {
fragment ItemMeSatsSubmit on Item {
path
sats
meSats
@ -271,7 +271,7 @@ export function useZap () {
const item = cache.readFragment({
id: `Item:${id}`,
fragment: gql`
fragment ItemMeSats on Item {
fragment ItemMeSatsZap on Item {
meSats
}
`
@ -338,7 +338,7 @@ export function useZap () {
const fragment = {
id: `Item:${itemId}`,
fragment: gql`
fragment ItemMeSats on Item {
fragment ItemMeSatsUndos on Item {
sats
meSats
}

View File

@ -13,7 +13,7 @@ export default function Qr ({ asIs, value, webLn, statusVariant, description, st
useEffect(() => {
async function effect () {
if (webLn && provider?.enabled) {
if (webLn && provider) {
try {
await provider.sendPayment({ bolt11: value })
} catch (e) {

View File

@ -5,12 +5,33 @@ import Gear from '@/svgs/settings-5-fill.svg'
import Link from 'next/link'
import CancelButton from './cancel-button'
import { SubmitButton } from './form'
import { Status } from './webln'
export const isConfigured = status => [Status.Enabled, Status.Locked, Status.Error, true].includes(status)
export function WalletCard ({ title, badges, provider, status }) {
const configured = isConfigured(status)
let indicator = styles.disabled
switch (status) {
case Status.Enabled:
case true:
indicator = styles.success
break
case Status.Locked:
indicator = styles.warning
break
case Status.Error:
indicator = styles.error
break
case Status.Initialized:
case false:
indicator = styles.disabled
break
}
export function WalletCard ({ title, badges, provider, enabled }) {
const isConfigured = enabled === true || enabled === false
return (
<Card className={styles.card}>
<div className={`${styles.indicator} ${enabled === true ? styles.success : enabled === false ? styles.error : styles.disabled}`} />
<div className={`${styles.indicator} ${indicator}`} />
<Card.Body>
<Card.Title>{title}</Card.Title>
<Card.Subtitle className='mt-2'>
@ -24,7 +45,7 @@ export function WalletCard ({ title, badges, provider, enabled }) {
{provider &&
<Link href={`/settings/wallets/${provider}`}>
<Card.Footer className={styles.attach}>
{isConfigured
{configured
? <>configure<Gear width={14} height={14} /></>
: <>attach<Plug width={14} height={14} /></>}
</Card.Footer>
@ -34,19 +55,20 @@ export function WalletCard ({ title, badges, provider, enabled }) {
}
export function WalletButtonBar ({
enabled, disable,
status, disable,
className, children, onDelete, onCancel, hasCancel = true,
createText = 'attach', deleteText = 'unattach', editText = 'save'
}) {
const configured = isConfigured(status)
return (
<div className={`mt-3 ${className}`}>
<div className='d-flex justify-content-between'>
{enabled !== undefined &&
{configured &&
<Button onClick={onDelete} variant='grey-medium'>{deleteText}</Button>}
{children}
<div className='d-flex align-items-center ms-auto'>
{hasCancel && <CancelButton onClick={onCancel} />}
<SubmitButton variant='primary' disabled={disable}>{enabled ? editText : createText}</SubmitButton>
<SubmitButton variant='primary' disabled={disable}>{configured ? editText : createText}</SubmitButton>
</div>
</div>
</div>

View File

@ -3,29 +3,39 @@ import { LNbitsProvider, useLNbits } from './lnbits'
import { NWCProvider, useNWC } from './nwc'
import { useToast, withToastFlow } from '@/components/toast'
import { gql, useMutation } from '@apollo/client'
import { LNCProvider, useLNC } from './lnc'
const WebLNContext = createContext({})
const syncProvider = (array, provider) => {
const idx = array.findIndex(({ name }) => provider.name === name)
const enabled = [Status.Enabled, Status.Locked].includes(provider.status)
if (idx === -1) {
// add provider to end if enabled
return provider.enabled ? [...array, provider] : array
return enabled ? [...array, provider] : array
}
return [
...array.slice(0, idx),
// remove provider if not enabled
...provider.enabled ? [provider] : [],
...enabled ? [provider] : [],
...array.slice(idx + 1)
]
}
const storageKey = 'webln:providers'
export const Status = {
Initialized: 'Initialized',
Enabled: 'Enabled',
Locked: 'Locked',
Error: 'Error'
}
function RawWebLNProvider ({ children }) {
const lnbits = useLNbits()
const nwc = useNWC()
const availableProviders = [lnbits, nwc]
const lnc = useLNC()
const availableProviders = [lnbits, nwc, lnc]
const [enabledProviders, setEnabledProviders] = useState([])
// restore order on page reload
@ -42,7 +52,7 @@ function RawWebLNProvider ({ children }) {
return null
})
})
}, [])
}, [...availableProviders])
// keep list in sync with underlying providers
useEffect(() => {
@ -53,20 +63,13 @@ function RawWebLNProvider ({ children }) {
// This can be the case if we're syncing from a page reload
// where the providers are initially not enabled.
// If provider is no longer enabled, it is removed from the list.
const isInitialized = p => p.initialized
const isInitialized = p => [Status.Enabled, Status.Locked, Status.Initialized].includes(p.status)
const newProviders = availableProviders.filter(isInitialized).reduce(syncProvider, providers)
const newOrder = newProviders.map(({ name }) => name)
window.localStorage.setItem(storageKey, JSON.stringify(newOrder))
return newProviders
})
}, [lnbits, nwc])
// sanity check
for (const p of enabledProviders) {
if (!p.enabled && p.initialized) {
console.warn('Expected provider to be enabled but is not:', p.name)
}
}
}, [...availableProviders])
// first provider in list is the default provider
// TODO: implement fallbacks via provider priority
@ -87,7 +90,12 @@ function RawWebLNProvider ({ children }) {
return {
flowId: flowId || hash,
type: 'payment',
onPending: () => provider.sendPayment(bolt11),
onPending: async () => {
if (provider.status === Status.Locked) {
await provider.unlock()
}
await provider.sendPayment(bolt11)
},
// hash and hmac are only passed for JIT invoices
onCancel: () => hash && hmac ? cancelInvoice({ variables: { hash, hmac } }) : undefined,
timeout: expiresIn
@ -107,9 +115,8 @@ function RawWebLNProvider ({ children }) {
})
}, [setEnabledProviders])
const value = { provider: { ...provider, sendPayment: sendPaymentWithToast }, enabledProviders, setProvider }
return (
<WebLNContext.Provider value={value}>
<WebLNContext.Provider value={{ provider: provider ? { sendPayment: sendPaymentWithToast } : null, enabledProviders, setProvider }}>
{children}
</WebLNContext.Provider>
)
@ -119,9 +126,11 @@ export function WebLNProvider ({ children }) {
return (
<LNbitsProvider>
<NWCProvider>
<RawWebLNProvider>
{children}
</RawWebLNProvider>
<LNCProvider>
<RawWebLNProvider>
{children}
</RawWebLNProvider>
</LNCProvider>
</NWCProvider>
</LNbitsProvider>
)

View File

@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { useWalletLogger } from '../logger'
import lnpr from 'bolt11'
import { Status } from '.'
import { bolt11Tags } from '@/lib/bolt11'
// Reference: https://github.com/getAlby/bitcoin-connect/blob/v3.2.0-alpha/src/connectors/LnbitsConnector.ts
@ -65,8 +66,7 @@ const getPayment = async (baseUrl, adminKey, paymentHash) => {
export function LNbitsProvider ({ children }) {
const [url, setUrl] = useState('')
const [adminKey, setAdminKey] = useState('')
const [enabled, setEnabled] = useState()
const [initialized, setInitialized] = useState(false)
const [status, setStatus] = useState()
const logger = useWalletLogger('lnbits')
const name = 'LNbits'
@ -90,9 +90,9 @@ export function LNbitsProvider ({ children }) {
}, [url, adminKey])
const sendPayment = useCallback(async (bolt11) => {
const inv = lnpr.decode(bolt11)
const hash = inv.tagsObject.payment_hash
const hash = bolt11Tags(bolt11).payment_hash
logger.info('sending payment:', `payment_hash=${hash}`)
try {
const response = await postPayment(url, adminKey, bolt11)
const checkResponse = await getPayment(url, adminKey, response.payment_hash)
@ -110,9 +110,8 @@ export function LNbitsProvider ({ children }) {
const loadConfig = useCallback(async () => {
const configStr = window.localStorage.getItem(storageKey)
setStatus(Status.Initialized)
if (!configStr) {
setEnabled(undefined)
setInitialized(true)
logger.info('no existing config found')
return
}
@ -133,15 +132,12 @@ export function LNbitsProvider ({ children }) {
logger.info('trying to fetch wallet')
await getWallet(url, adminKey)
logger.ok('wallet found')
setEnabled(true)
setStatus(Status.Enabled)
logger.ok('wallet enabled')
} catch (err) {
logger.error('invalid config:', err)
setEnabled(false)
logger.info('wallet disabled')
throw err
} finally {
setInitialized(true)
}
}, [logger])
@ -165,28 +161,28 @@ export function LNbitsProvider ({ children }) {
logger.info('trying to fetch wallet')
await getWallet(config.url, config.adminKey)
logger.ok('wallet found')
setStatus(Status.Enabled)
logger.ok('wallet enabled')
} catch (err) {
logger.error('invalid config:', err)
setEnabled(false)
setStatus(Status.Error)
logger.info('wallet disabled')
throw err
}
setEnabled(true)
logger.ok('wallet enabled')
}, [])
const clearConfig = useCallback(() => {
window.localStorage.removeItem(storageKey)
setUrl('')
setAdminKey('')
setEnabled(undefined)
setStatus(undefined)
}, [])
useEffect(() => {
loadConfig().catch(console.error)
}, [])
const value = { name, url, adminKey, initialized, enabled, saveConfig, clearConfig, getInfo, sendPayment }
const value = { name, url, adminKey, status, saveConfig, clearConfig, getInfo, sendPayment }
return (
<LNbitsContext.Provider value={value}>
{children}

198
components/webln/lnc.js Normal file
View File

@ -0,0 +1,198 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { useWalletLogger } from '../logger'
import LNC from '@lightninglabs/lnc-web'
import { Status } 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'
const LNCContext = createContext()
const mutex = new Mutex()
async function getLNC () {
if (window.lnc) return window.lnc
window.lnc = new LNC({ })
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 name = 'lnc'
const logger = useWalletLogger(name)
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 sendPayment = useCallback(async (bolt11) => {
const hash = bolt11Tags(bolt11).payment_hash
logger.info('sending payment:', `payment_hash=${hash}`)
return await mutex.runExclusive(async () => {
try {
// credentials need to be decrypted before connecting after a disconnect
lnc.credentials.password = config?.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) {
logger.error('payment failed:', `payment_hash=${hash}`, err.message || err.toString?.())
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, config])
const saveConfig = useCallback(async config => {
setConfig(config)
console.log(config)
try {
lnc.credentials.pairingPhrase = config.pairingPhrase
lnc.credentials.password = config?.password || XXX_DEFAULT_PASSWORD
await lnc.connect()
await validateNarrowPerms(lnc)
lnc.credentials.password = config?.password || XXX_DEFAULT_PASSWORD
setStatus(Status.Enabled)
logger.ok('wallet enabled')
} catch (err) {
setStatus(Status.Error)
logger.error('invalid config:', err)
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)
logger.info('cleared config')
}, [logger, lnc])
const unlock = useCallback(async (connect) => {
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()
} 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])
useEffect(() => {
(async () => {
try {
const lnc = await getLNC()
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) {
setStatus(Status.Error)
logger.error('wallet could not be loaded', err)
}
})()
}, [setStatus, setConfig, logger])
return (
<LNCContext.Provider value={{ name, status, unlock, getInfo, sendPayment, config, saveConfig, clearConfig }}>
{children}
{modal}
</LNCContext.Provider>
)
}
export function useLNC () {
return useContext(LNCContext)
}

View File

@ -4,7 +4,8 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea
import { Relay, finalizeEvent, nip04 } from 'nostr-tools'
import { parseNwcUrl } from '@/lib/url'
import { useWalletLogger } from '../logger'
import lnpr from 'bolt11'
import { Status } from '.'
import { bolt11Tags } from '@/lib/bolt11'
const NWCContext = createContext()
@ -13,8 +14,7 @@ export function NWCProvider ({ children }) {
const [walletPubkey, setWalletPubkey] = useState()
const [relayUrl, setRelayUrl] = useState()
const [secret, setSecret] = useState()
const [enabled, setEnabled] = useState()
const [initialized, setInitialized] = useState(false)
const [status, setStatus] = useState()
const logger = useWalletLogger('nwc')
const name = 'NWC'
@ -97,9 +97,8 @@ export function NWCProvider ({ children }) {
const loadConfig = useCallback(async () => {
const configStr = window.localStorage.getItem(storageKey)
setStatus(Status.Initialized)
if (!configStr) {
setEnabled(undefined)
setInitialized(true)
logger.info('no existing config found')
return
}
@ -122,14 +121,11 @@ export function NWCProvider ({ children }) {
try {
await validateParams(params)
setEnabled(true)
setStatus(Status.Enabled)
logger.ok('wallet enabled')
} catch (err) {
setEnabled(false)
logger.info('wallet disabled')
throw err
} finally {
setInitialized(true)
}
}, [validateParams, logger])
@ -138,7 +134,7 @@ export function NWCProvider ({ children }) {
const { nwcUrl } = config
setNwcUrl(nwcUrl)
if (!nwcUrl) {
setEnabled(undefined)
setStatus(undefined)
return
}
@ -159,10 +155,10 @@ export function NWCProvider ({ children }) {
try {
await validateParams(params)
setEnabled(true)
setStatus(Status.Enabled)
logger.ok('wallet enabled')
} catch (err) {
setEnabled(false)
setStatus(Status.Error)
logger.info('wallet disabled')
throw err
}
@ -174,12 +170,11 @@ export function NWCProvider ({ children }) {
setRelayUrl(undefined)
setWalletPubkey(undefined)
setSecret(undefined)
setEnabled(undefined)
setStatus(undefined)
}, [])
const sendPayment = useCallback(async (bolt11) => {
const inv = lnpr.decode(bolt11)
const hash = inv.tagsObject.payment_hash
const hash = bolt11Tags(bolt11).payment_hash
logger.info('sending payment:', `payment_hash=${hash}`)
let relay, sub
@ -262,7 +257,7 @@ export function NWCProvider ({ children }) {
loadConfig().catch(err => logger.error(err.message || err.toString?.()))
}, [])
const value = { name, nwcUrl, relayUrl, walletPubkey, secret, initialized, enabled, saveConfig, clearConfig, getInfo, sendPayment }
const value = { name, nwcUrl, relayUrl, walletPubkey, secret, status, saveConfig, clearConfig, getInfo, sendPayment }
return (
<NWCContext.Provider value={value}>
{children}

View File

@ -334,6 +334,7 @@ services:
- '--tlsextradomain=host.docker.internal'
- '--listen=0.0.0.0:9735'
- '--rpclisten=0.0.0.0:10009'
- '--rpcmiddleware.enable'
- '--restlisten=0.0.0.0:8080'
- '--bitcoin.active'
- '--bitcoin.regtest'
@ -350,6 +351,7 @@ services:
- '--maxpendingchannels=10'
expose:
- "9735"
- "10009"
ports:
- "${STACKER_LND_REST_PORT}:8080"
- "${STACKER_LND_GRPC_PORT}:10009"
@ -367,6 +369,37 @@ services:
--min_confs 0 --local_amt=1000000000 --push_amt=500000000
fi
"
litd:
container_name: litd
image: lightninglabs/lightning-terminal:v0.12.4-alpha
profiles:
- payments
restart: unless-stopped
healthcheck:
<<: *healthcheck
test: ["CMD", "curl", "-f", "http://localhost:8443"]
depends_on:
stacker_lnd:
condition: service_healthy
restart: true
volumes:
- stacker_lnd:/lnd
ports:
- "8443:8443"
command:
- 'litd'
- '--httpslisten=0.0.0.0:8444'
- '--insecure-httplisten=0.0.0.0:8443'
- '--uipassword=password'
- '--lnd-mode=remote'
- '--network=regtest'
- '--remote.lit-debuglevel=debug'
- '--remote.lnd.rpcserver=stacker_lnd:10009'
- '--remote.lnd.macaroonpath=/lnd/data/chain/bitcoin/regtest/admin.macaroon'
- '--remote.lnd.tlscertpath=/lnd/tls.cert'
- '--autopilot.disable'
- '--pool.auctionserver=test.pool.lightning.finance:12010'
- '--loop.server.host=test.swap.lightning.today:11010'
stacker_cln:
build:
context: ./docker/cln

2050
lib/bip39-words.js Normal file

File diff suppressed because it is too large Load Diff

5
lib/bolt11.js Normal file
View File

@ -0,0 +1,5 @@
import { decode } from 'bolt11'
export function bolt11Tags (bolt11) {
return decode(bolt11).tagsObject
}

View File

@ -13,6 +13,7 @@ import { isInvoicableMacaroon, isInvoiceMacaroon } from './macaroon'
import { parseNwcUrl } from './url'
import { datePivot } from './time'
import { decodeRune } from '@/lib/cln'
import bip39Words from './bip39-words'
const { SUB } = subsFragments
const { NAME_QUERY } = usersFragments
@ -634,6 +635,21 @@ export const nwcSchema = object({
})
})
export const lncSchema = object({
pairingPhrase: array()
.transform(function (value, originalValue) {
if (this.isType(value) && value !== null) {
return value
}
return originalValue ? originalValue.split(/[\s]+/) : []
})
.of(string().trim().oneOf(bip39Words, ({ value }) => `'${value}' is not a valid pairing phrase word`))
.min(2, 'needs at least two words')
.max(10, 'max 10 words')
.required('required'),
password: string()
})
export const bioSchema = object({
bio: string().required('required').trim()
})

29
package-lock.json generated
View File

@ -13,6 +13,7 @@
"@as-integrations/next": "^2.0.2",
"@auth/prisma-adapter": "^1.0.3",
"@graphql-tools/schema": "^10.0.0",
"@lightninglabs/lnc-web": "^0.3.1-alpha",
"@noble/curves": "^1.2.0",
"@opensearch-project/opensearch": "^2.4.0",
"@prisma/client": "^5.4.2",
@ -21,6 +22,7 @@
"@yudiel/react-qr-scanner": "^1.1.10",
"acorn": "^8.10.0",
"ajv": "^8.12.0",
"async-mutex": "^0.5.0",
"async-retry": "^1.3.1",
"aws-sdk": "^2.1473.0",
"bech32": "^2.0.0",
@ -3815,6 +3817,20 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lightninglabs/lnc-core": {
"version": "0.3.1-alpha",
"resolved": "https://registry.npmjs.org/@lightninglabs/lnc-core/-/lnc-core-0.3.1-alpha.tgz",
"integrity": "sha512-I/hThdItLWJ6RU8Z27ZIXhpBS2JJuD3+TjtaQXX2CabaUYXlcN4sk+Kx8N/zG/fk8qZvjlRWum4vHu4ZX554Fg=="
},
"node_modules/@lightninglabs/lnc-web": {
"version": "0.3.1-alpha",
"resolved": "https://registry.npmjs.org/@lightninglabs/lnc-web/-/lnc-web-0.3.1-alpha.tgz",
"integrity": "sha512-yL5SgBkl6kd6ISzJHGlSN7TXbiDoo1pfGvTOIdVWYVyXtEeW8PT+x6YGOmyQXGFT2OOf7fC7PfP9VnskDPuFaA==",
"dependencies": {
"@lightninglabs/lnc-core": "0.3.1-alpha",
"crypto-js": "4.2.0"
}
},
"node_modules/@next/env": {
"version": "13.5.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-13.5.4.tgz",
@ -5840,6 +5856,14 @@
"resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz",
"integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg=="
},
"node_modules/async-mutex": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
"integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/async-retry": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz",
@ -7437,6 +7461,11 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
},
"node_modules/crypto-random-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",

View File

@ -18,6 +18,7 @@
"@as-integrations/next": "^2.0.2",
"@auth/prisma-adapter": "^1.0.3",
"@graphql-tools/schema": "^10.0.0",
"@lightninglabs/lnc-web": "^0.3.1-alpha",
"@noble/curves": "^1.2.0",
"@opensearch-project/opensearch": "^2.4.0",
"@prisma/client": "^5.4.2",
@ -26,6 +27,7 @@
"@yudiel/react-qr-scanner": "^1.1.10",
"acorn": "^8.10.0",
"ajv": "^8.12.0",
"async-mutex": "^0.5.0",
"async-retry": "^1.3.1",
"aws-sdk": "^2.1473.0",
"bech32": "^2.0.0",

View File

@ -130,7 +130,7 @@ export function CLNCard ({ wallet }) {
title='CLN'
badges={['receive only', 'non-custodial']}
provider='cln'
enabled={wallet !== undefined || undefined}
status={wallet !== undefined || undefined}
/>
)
}

View File

@ -10,6 +10,8 @@ import { CLNCard } from './cln'
import { WALLETS } from '@/fragments/wallet'
import { useQuery } from '@apollo/client'
import PageLoading from '@/components/page-loading'
import { LNCCard } from './lnc'
import Link from 'next/link'
export const getServerSideProps = getGetServerSideProps({ query: WALLETS, authRequired: true })
@ -27,12 +29,18 @@ export default function Wallet ({ ssrData }) {
<div className='py-5 w-100'>
<h2 className='mb-2 text-center'>attach wallets</h2>
<h6 className='text-muted text-center'>attach wallets to supplement your SN wallet</h6>
<div className='text-center'>
<Link href='/wallet/logs' className='text-muted fw-bold text-underline'>
wallet logs
</Link>
</div>
<div className={styles.walletGrid}>
<LightningAddressWalletCard wallet={lnaddr} />
<LNDCard wallet={lnd} />
<CLNCard wallet={cln} />
<LNbitsCard />
<NWCCard />
<LNCCard />
<WalletCard title='coming soon' badges={['probably']} />
<WalletCard title='coming soon' badges={['we hope']} />
<WalletCard title='coming soon' badges={['tm']} />

View File

@ -99,7 +99,7 @@ export function LightningAddressWalletCard ({ wallet }) {
title='lightning address'
badges={['receive only', 'non-custodialish']}
provider='lightning-address'
enabled={wallet !== undefined || undefined}
status={wallet !== undefined || undefined}
/>
)
}

View File

@ -1,7 +1,7 @@
import { getGetServerSideProps } from '@/api/ssrApollo'
import { Form, ClientInput, ClientCheckbox, PasswordInput } from '@/components/form'
import { CenterLayout } from '@/components/layout'
import { WalletButtonBar, WalletCard } from '@/components/wallet-card'
import { WalletButtonBar, WalletCard, isConfigured } from '@/components/wallet-card'
import { lnbitsSchema } from '@/lib/validate'
import { useToast } from '@/components/toast'
import { useRouter } from 'next/router'
@ -15,8 +15,9 @@ export const getServerSideProps = getGetServerSideProps({ authRequired: true })
export default function LNbits () {
const { provider, enabledProviders, setProvider } = useWebLNConfigurator()
const lnbits = useLNbits()
const { name, url, adminKey, saveConfig, clearConfig, enabled } = lnbits
const { name, url, adminKey, saveConfig, clearConfig, status } = lnbits
const isDefault = provider?.name === name
const configured = isConfigured(status)
const toaster = useToast()
const router = useRouter()
@ -59,13 +60,13 @@ export default function LNbits () {
required
/>
<ClientCheckbox
disabled={!enabled || isDefault || enabledProviders.length === 1}
disabled={!configured || isDefault || enabledProviders.length === 1}
initialValue={isDefault}
label='default payment method'
name='isDefault'
/>
<WalletButtonBar
enabled={enabled} onDelete={async () => {
status={status} onDelete={async () => {
try {
await clearConfig()
toaster.success('saved settings')
@ -85,13 +86,13 @@ export default function LNbits () {
}
export function LNbitsCard () {
const { enabled } = useLNbits()
const { status } = useLNbits()
return (
<WalletCard
title='LNbits'
badges={['send only', 'non-custodialish']}
provider='lnbits'
enabled={enabled}
status={status}
/>
)
}

View File

@ -0,0 +1,119 @@
import { getGetServerSideProps } from '@/api/ssrApollo'
import { WalletSecurityBanner } from '@/components/banners'
import { ClientCheckbox, Form, PasswordInput } from '@/components/form'
import Info from '@/components/info'
import { CenterLayout } from '@/components/layout'
import Text from '@/components/text'
import { useToast } from '@/components/toast'
import { WalletButtonBar, WalletCard } from '@/components/wallet-card'
import WalletLogs from '@/components/wallet-logs'
import { Status, useWebLNConfigurator } from '@/components/webln'
import { XXX_DEFAULT_PASSWORD, useLNC } from '@/components/webln/lnc'
import { lncSchema } from '@/lib/validate'
import { useRouter } from 'next/router'
import { useEffect, useRef } from 'react'
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
export default function LNC () {
const { provider, enabledProviders, setProvider } = useWebLNConfigurator()
const toaster = useToast()
const router = useRouter()
const lnc = useLNC()
const { status, clearConfig, saveConfig, config, name, unlock } = lnc
const isDefault = provider?.name === name
const unlocking = useRef(false)
useEffect(() => {
if (!unlocking.current && status === Status.Locked) {
unlocking.current = true
unlock()
}
}, [status, unlock])
const defaultPassword = config?.password === XXX_DEFAULT_PASSWORD
return (
<CenterLayout>
<h2>Lightning Node Connect for LND</h2>
<h6 className='text-muted text-center pb-3'>use Lightning Node Connect for LND payments</h6>
<WalletSecurityBanner />
<Form
initial={{
pairingPhrase: config?.pairingPhrase || '',
password: (!config?.password || defaultPassword) ? '' : config.password
}}
schema={lncSchema}
onSubmit={async ({ isDefault, ...values }) => {
try {
await clearConfig()
await saveConfig(values)
if (isDefault) setProvider(lnc)
toaster.success('saved settings')
router.push('/settings/wallets')
} catch (err) {
console.error(err)
toaster.danger('failed to attach: ' + err.message || err.toString?.())
}
}}
>
<PasswordInput
label={
<div className='d-flex align-items-center'>pairing phrase
<Info label='help'>
<Text>
{'We only need permissions for the uri `/lnrpc.Lightning/SendPaymentSync`\n\nCreate a budgeted account with narrow permissions:\n\n```$ litcli accounts create --balance <budget>```\n\n```$ litcli sessions add --type custom --account_id <account_id> --uri /lnrpc.Lightning/SendPaymentSync```'}
</Text>
</Info>
</div>
}
name='pairingPhrase'
initialValue={config?.pairingPhrase}
newPass={config?.pairingPhrase === undefined}
required
autoFocus
/>
<PasswordInput
label={<>password <small className='text-muted ms-2'>optional</small></>}
name='password'
initialValue={defaultPassword ? '' : config?.password}
newPass={config?.password === undefined || defaultPassword}
hint='encrypts your pairing phrase when stored locally'
/>
<ClientCheckbox
disabled={status !== Status.Enabled || isDefault || enabledProviders?.length === 1}
initialValue={isDefault}
label='default payment method'
name='isDefault'
/>
<WalletButtonBar
status={status} onDelete={async () => {
try {
await clearConfig()
toaster.success('saved settings')
router.push('/settings/wallets')
} catch (err) {
console.error(err)
toaster.danger('failed to unattach: ' + err.message || err.toString?.())
}
}}
/>
</Form>
<div className='mt-3 w-100'>
<WalletLogs wallet='lnc' embedded />
</div>
</CenterLayout>
)
}
export function LNCCard () {
const { status } = useLNC()
return (
<WalletCard
title='LNC'
badges={['send only', 'non-custodial', 'budgetable']}
provider='lnc'
status={status}
/>
)
}

View File

@ -106,7 +106,7 @@ export default function LND ({ ssrData }) {
/>
<AutowithdrawSettings />
<WalletButtonBar
enabled={!!wallet} onDelete={async () => {
status={!!wallet} onDelete={async () => {
try {
await removeWallet({ variables: { id: wallet?.id } })
toaster.success('saved settings')
@ -130,7 +130,7 @@ export function LNDCard ({ wallet }) {
title='LND'
badges={['receive only', 'non-custodial']}
provider='lnd'
enabled={wallet !== undefined || undefined}
status={wallet !== undefined || undefined}
/>
)
}

View File

@ -1,7 +1,7 @@
import { getGetServerSideProps } from '@/api/ssrApollo'
import { Form, ClientCheckbox, PasswordInput } from '@/components/form'
import { CenterLayout } from '@/components/layout'
import { WalletButtonBar, WalletCard } from '@/components/wallet-card'
import { WalletButtonBar, WalletCard, isConfigured } from '@/components/wallet-card'
import { nwcSchema } from '@/lib/validate'
import { useToast } from '@/components/toast'
import { useRouter } from 'next/router'
@ -15,8 +15,9 @@ export const getServerSideProps = getGetServerSideProps({ authRequired: true })
export default function NWC () {
const { provider, enabledProviders, setProvider } = useWebLNConfigurator()
const nwc = useNWC()
const { name, nwcUrl, saveConfig, clearConfig, enabled } = nwc
const { name, nwcUrl, saveConfig, clearConfig, status } = nwc
const isDefault = provider?.name === name
const configured = isConfigured(status)
const toaster = useToast()
const router = useRouter()
@ -52,13 +53,13 @@ export default function NWC () {
autoFocus
/>
<ClientCheckbox
disabled={!enabled || isDefault || enabledProviders.length === 1}
disabled={!configured || isDefault || enabledProviders.length === 1}
initialValue={isDefault}
label='default payment method'
name='isDefault'
/>
<WalletButtonBar
enabled={enabled} onDelete={async () => {
status={status} onDelete={async () => {
try {
await clearConfig()
toaster.success('saved settings')
@ -78,13 +79,13 @@ export default function NWC () {
}
export function NWCCard () {
const { enabled } = useNWC()
const { status } = useNWC()
return (
<WalletCard
title='NWC'
badges={['send only', 'non-custodialish', 'budgetable']}
provider='nwc'
enabled={enabled}
status={status}
/>
)
}

View File

@ -84,11 +84,6 @@ function WalletHistory () {
wallet history
</Link>
</div>
<div>
<Link href='/wallet/logs' className='text-muted fw-bold text-underline'>
wallet logs
</Link>
</div>
</div>
)
}

View File

@ -55,6 +55,12 @@
filter: drop-shadow(0 0 2px #c92020);
}
.indicator.warning {
color: var(--bs-orange) !important;
background-color: var(--bs-orange) !important;
border: 1px solid var(--bs-secondary);
}
.indicator.disabled {
color: var(--theme-toolbarHover) !important;
background-color: var(--theme-toolbarHover) !important;