Wallet flow (#2362)

* Wallet flow

* Prepopulate fields of complementary protocol

* Remove TODO about one mutation for save

We need to save protocols in separate mutations so we can use the wallet id returned by the first protocol save for the following protocol saves and save them all to the same wallet.

* Fix badges not updated on wallet delete

* Fix useProtocol call

* Fix lightning address save via prompt

* Don't pass share as attribute to DOM

* Fix useCallback dependency

* Progress numbers as SVGs

* Fix progress line margins

* Remove unused saveWallet arguments

* Update cache with settings response

* Fix line does not connect with number 1

* Don't reuse page nav arrows in form nav

* Fix missing SVG hover style

* Fix missing space in wallet save log message

* Reuse CSS from nav.module.css

* align buttons and their icons/text

* center form progress line

* increase top padding of form on smaller screens

* provide margin above button bar on settings form

---------

Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
ekzyis 2025-08-26 16:19:52 +02:00 committed by GitHub
parent a620c0b0ce
commit e46f4f01b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1003 additions and 627 deletions

View File

@ -139,8 +139,7 @@ const typeDefs = gql`
): Boolean!
# delete
removeWallet(id: ID!): Boolean
removeWalletProtocol(id: ID!): Boolean
deleteWallet(id: ID!): Boolean
# crypto
updateWalletEncryption(keyHash: String!, wallets: [WalletEncryptionUpdate!]!): Boolean
@ -149,7 +148,7 @@ const typeDefs = gql`
disablePassphraseExport: Boolean
# settings
setWalletSettings(settings: WalletSettingsInput!): Boolean
setWalletSettings(settings: WalletSettingsInput!): WalletSettings!
setWalletPriorities(priorities: [WalletPriorityUpdate!]!): Boolean
# logs

View File

@ -0,0 +1,223 @@
import { createContext, Fragment, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import classNames from 'classnames'
import { useRouter } from 'next/router'
const MultiStepFormContext = createContext()
export function MultiStepForm ({ children, initial, steps }) {
const [stepIndex, setStepIndex] = useState(0)
const [formState, setFormState] = useState({})
const router = useRouter()
useEffect(() => {
// initial state might not be available on first render so we sync changes
if (initial) setFormState(initial)
}, [initial])
useEffect(() => {
const idx = Math.max(0, steps.indexOf(router.query.step))
setStepIndex(idx)
router.replace({
pathname: router.pathname,
query: { type: router.query.type, step: steps[idx] }
}, null, { shallow: true })
}, [router.query.step, steps])
const next = useCallback(() => {
const idx = Math.min(stepIndex + 1, steps.length - 1)
router.push(
{ pathname: router.pathname, query: { type: router.query.type, step: steps[idx] } },
null,
{ shallow: true }
)
}, [stepIndex, steps, router])
const prev = useCallback(() => router.back(), [router])
const updateFormState = useCallback((id, state) => {
setFormState(formState => {
return id ? { ...formState, [id]: state } : state
})
}, [])
const value = useMemo(
() => ({ stepIndex, steps, next, prev, formState, updateFormState }),
[stepIndex, steps, next, prev, formState, updateFormState])
return (
<MultiStepFormContext.Provider value={value}>
<Progress />
{children[stepIndex]}
</MultiStepFormContext.Provider>
)
}
function Progress () {
const steps = useSteps()
const stepIndex = useStepIndex()
const style = (index) => {
switch (index) {
case 0: return { marginLeft: '-5px', marginRight: '-13px' }
case 1: return { marginLeft: '-13px', marginRight: '-15px' }
default: return {}
}
}
return (
<div className='d-flex my-3 mx-auto'>
{
steps.map((label, i) => {
const last = i === steps.length - 1
return (
<Fragment key={i}>
<ProgressNumber number={i + 1} label={label} active={stepIndex >= i} />
{!last && <ProgressLine style={style(i)} active={stepIndex >= i + 1} />}
</Fragment>
)
})
}
</div>
)
}
function ProgressNumber ({ number, label, active }) {
return (
<div className={classNames('z-1 text-center', { 'text-info': active })}>
<NumberSVG number={number} active={active} />
<div className={classNames('small pt-1', active ? 'text-info' : 'text-muted')}>
{label}
</div>
</div>
)
}
const NUMBER_SVG_WIDTH = 24
const NUMBER_SVG_HEIGHT = 24
function NumberSVG ({ number, active }) {
const width = NUMBER_SVG_WIDTH
const height = NUMBER_SVG_HEIGHT
const Wrapper = ({ children }) => (
<div style={{ position: 'relative', width: `${width}px`, height: `${height}px`, margin: '0 auto' }}>
{children}
</div>
)
const Circle = () => {
const circleProps = {
fill: active ? 'var(--bs-info)' : 'var(--bs-body-bg)',
stroke: active ? 'var(--bs-info)' : 'var(--theme-grey)'
}
return (
<svg
xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'
width={width} height={height}
style={{ position: 'absolute', top: 0, left: 0 }}
>
<circle cx='12' cy='12' r='11' strokeWidth='1' {...circleProps} />
</svg>
)
}
const Number = () => {
const svgProps = {
xmlns: 'http://www.w3.org/2000/svg',
viewBox: '0 0 24 24',
// we scale the number down and render it in the center of the circle
width: 0.5 * width,
height: 0.5 * height,
style: { position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }
}
const numberColor = active ? 'var(--bs-white)' : 'var(--theme-grey)'
// svgs are from https://remixicon.com/icon/number-1 etc.
switch (number) {
case 1:
return (
<svg {...svgProps}>
<path fill={numberColor} d='M14 1.5V22H12V3.704L7.5 4.91V2.839L12.5 1.5H14Z' />
</svg>
)
case 2:
return (
<svg {...svgProps}>
<path
fill={numberColor}
d='M16.0002 7.5C16.0002 5.29086 14.2094 3.5 12.0002 3.5C9.7911 3.5 8.00024 5.29086 8.00024 7.5H6.00024C6.00024 4.18629 8.68653 1.5 12.0002 1.5C15.314 1.5 18.0002 4.18629 18.0002 7.5C18.0002 8.93092 17.4993 10.2448 16.6633 11.276L9.344 19.9991L18.0002 20V22H6.00024L6 20.8731L15.0642 10.071C15.6485 9.37595 16.0002 8.47905 16.0002 7.5Z'
/>
</svg>
)
case 3:
return (
<svg {...svgProps}>
<path fill={numberColor} d='M18.0001 2V3.36217L12.8087 9.54981C16.0169 9.94792 18.5001 12.684 18.5001 16C18.5001 19.5899 15.5899 22.5 12.0001 22.5C8.95434 22.5 6.39789 20.4052 5.69287 17.5778L7.63351 17.0922C8.12156 19.0497 9.89144 20.5 12.0001 20.5C14.4853 20.5 16.5001 18.4853 16.5001 16C16.5001 13.5147 14.4853 11.5 12.0001 11.5C11.2795 11.5 10.5985 11.6694 9.99465 11.9705L9.76692 12.0923L9.07705 10.8852L14.8551 3.99917L6.50006 4V2H18.0001Z' />
</svg>
)
default:
return null
}
}
return (
<Wrapper>
<Circle />
<Number />
</Wrapper>
)
}
function ProgressLine ({ style, active }) {
const svgStyle = { display: 'block', position: 'relative', top: `${NUMBER_SVG_HEIGHT / 2}px` }
return (
<div style={style}>
<svg style={svgStyle} width='100%' height='1' viewBox='0 0 100 1' preserveAspectRatio='none'>
<path
d='M 0 1 L 100 1'
stroke={active ? 'var(--bs-info)' : 'var(--theme-grey)'}
strokeWidth='1'
fill='none'
/>
</svg>
</div>
)
}
function useSteps () {
const { steps } = useContext(MultiStepFormContext)
return steps
}
export function useStepIndex () {
const { stepIndex } = useContext(MultiStepFormContext)
return stepIndex
}
export function useMaxSteps () {
const steps = useSteps()
return steps.length
}
export function useStep () {
const stepIndex = useStepIndex()
const steps = useSteps()
return steps[stepIndex]
}
export function useNext () {
const { next } = useContext(MultiStepFormContext)
return next
}
export function usePrev () {
const { prev } = useContext(MultiStepFormContext)
return prev
}
export function useFormState (id) {
const { formState, updateFormState } = useContext(MultiStepFormContext)
const setFormState = useCallback(state => updateFormState(id, state), [id, updateFormState])
return useMemo(
() => [
id ? formState[id] : formState,
setFormState
], [formState, id, setFormState])
}

View File

@ -131,11 +131,6 @@ export const SET_SETTINGS = gql`
}
}`
export const DELETE_WALLET = gql`
mutation removeWallet {
removeWallet
}`
export const NAME_QUERY = gql`
query nameAvailable($name: String!) {
nameAvailable(name: $name)

View File

@ -1,6 +1,6 @@
import { getGetServerSideProps } from '@/api/ssrApollo'
import { useData } from '@/components/use-data'
import { WalletForms as WalletFormsComponent } from '@/wallets/client/components'
import { WalletMultiStepForm } from '@/wallets/client/components'
import { WALLET } from '@/wallets/client/fragments'
import { useDecryptedWallet } from '@/wallets/client/hooks'
import { unurlify } from '@/wallets/lib/util'
@ -8,19 +8,19 @@ import { useQuery } from '@apollo/client'
import { useRouter } from 'next/router'
const variablesFunc = params => {
const id = Number(params.slug[0])
return !Number.isNaN(id) ? { id } : { name: unurlify(params.slug[0]) }
const id = Number(params.type)
return !Number.isNaN(id) ? { id } : { name: unurlify(params.type) }
}
export const getServerSideProps = getGetServerSideProps({ query: WALLET, variables: variablesFunc, authRequired: true })
export default function WalletForms ({ ssrData }) {
export default function Wallet ({ ssrData }) {
const router = useRouter()
const variables = variablesFunc(router.query)
// this will print the following warning in the console:
// Warning: fragment with name WalletTemplateFields already exists.
// graphql-tag enforces all fragment names across your application to be unique
// this is not a problem because the warning is only meant to avoid overwriting fragments but we're reusing it
const { data, refetch } = useQuery(WALLET, { variables })
const { data } = useQuery(WALLET, { variables })
const dat = useData(data, ssrData)
const decryptedWallet = useDecryptedWallet(dat?.wallet)
@ -29,5 +29,5 @@ export default function WalletForms ({ ssrData }) {
return null
}
return <WalletFormsComponent wallet={wallet} refetch={refetch} />
return <WalletMultiStepForm wallet={wallet} />
}

View File

@ -113,8 +113,6 @@ export default function Wallet () {
<WalletLayoutSubHeader>use real bitcoin</WalletLayoutSubHeader>
<div className='text-center'>
<WalletLayoutLink href='/wallets/logs'>wallet logs</WalletLayoutLink>
<span className='mx-2'></span>
<WalletLayoutLink href='/wallets/settings'>settings</WalletLayoutLink>
{showPassphrase && (
<>
<span className='mx-2'></span>

View File

@ -1,185 +0,0 @@
import { getGetServerSideProps } from '@/api/ssrApollo'
import { Checkbox, Form, Input, SubmitButton } from '@/components/form'
import Info from '@/components/info'
import { isNumber } from '@/lib/format'
import { WalletLayout, WalletLayoutHeader, WalletLayoutSubHeader } from '@/wallets/client/components'
import { useMutation, useQuery } from '@apollo/client'
import Link from 'next/link'
import { useCallback, useMemo } from 'react'
import { InputGroup } from 'react-bootstrap'
import styles from '@/styles/wallet.module.css'
import classNames from 'classnames'
import { useField } from 'formik'
import { SET_WALLET_SETTINGS, WALLET_SETTINGS } from '@/wallets/client/fragments'
import { walletSettingsSchema } from '@/lib/validate'
import { useToast } from '@/components/toast'
import CancelButton from '@/components/cancel-button'
export const getServerSideProps = getGetServerSideProps({ query: WALLET_SETTINGS, authRequired: true })
export default function WalletSettings ({ ssrData }) {
const { data } = useQuery(WALLET_SETTINGS)
const [setSettings] = useMutation(SET_WALLET_SETTINGS)
const { walletSettings: settings } = useMemo(() => data ?? ssrData, [data, ssrData])
const toaster = useToast()
const initial = {
receiveCreditsBelowSats: settings?.receiveCreditsBelowSats,
sendCreditsBelowSats: settings?.sendCreditsBelowSats,
autoWithdrawThreshold: settings?.autoWithdrawThreshold ?? 10000,
autoWithdrawMaxFeePercent: settings?.autoWithdrawMaxFeePercent ?? 1,
autoWithdrawMaxFeeTotal: settings?.autoWithdrawMaxFeeTotal ?? 1,
proxyReceive: settings?.proxyReceive
}
const onSubmit = useCallback(async (values) => {
try {
await setSettings({
variables: {
settings: values
}
})
toaster.success('saved settings')
} catch (err) {
console.error(err)
toaster.danger('failed to save settings')
}
}, [toaster])
return (
<WalletLayout>
<div className='py-5 mx-auto w-100' style={{ maxWidth: '600px' }}>
<WalletLayoutHeader>wallet settings</WalletLayoutHeader>
<WalletLayoutSubHeader>apply globally to all wallets</WalletLayoutSubHeader>
<Form
enableReinitialize
initial={initial}
schema={walletSettingsSchema}
className='mt-3'
onSubmit={onSubmit}
>
<CowboyCreditsSettings />
<LightningAddressSettings />
<AutowithdrawSettings />
<LightningNetworkFeesSettings />
<div className='d-flex mt-1 justify-content-end'>
<CancelButton />
<SubmitButton variant='info'>save</SubmitButton>
</div>
</Form>
</div>
</WalletLayout>
)
}
function CowboyCreditsSettings () {
return (
<>
<Separator>cowboy credits</Separator>
<Input
label='receive credits for zaps below'
name='receiveCreditsBelowSats'
required
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
type='number'
min={0}
/>
<Input
label='send credits for zaps below'
name='sendCreditsBelowSats'
required
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
type='number'
min={0}
/>
</>
)
}
function LightningAddressSettings () {
return (
<>
<Separator>@stacker.news lightning address</Separator>
<Checkbox
label={
<div className='d-flex align-items-center'>enhance privacy of my lightning address
<Info>
<ul>
<li>Enabling this setting hides details (ie node pubkey) of your attached wallets when anyone pays your SN lightning address or lnurl-pay</li>
<li>The lightning invoice will appear to have SN's node as the destination to preserve your wallet's privacy</li>
<li>This will incur in a 10% fee</li>
<li>Disable this setting to receive payments directly to your attached wallets (which will reveal their details to the payer)</li>
<li>Note: this privacy behavior is standard for internal zaps/payments on SN, and this setting only applies to external payments</li>
</ul>
</Info>
</div>
}
name='proxyReceive'
groupClassName='mb-0'
/>
</>
)
}
function AutowithdrawSettings () {
const [{ value: threshold }] = useField('autoWithdrawThreshold')
const sendThreshold = Math.max(Math.floor(threshold / 10), 1)
return (
<>
<Separator>autowithdrawal</Separator>
<Input
label='desired balance'
name='autoWithdrawThreshold'
hint={isNumber(sendThreshold) ? `will attempt autowithdrawal when your balance exceeds ${sendThreshold * 11} sats` : undefined}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
required
type='number'
min={0}
/>
</>
)
}
function LightningNetworkFeesSettings () {
return (
<>
<Separator className='mb-0'>lightning network fees</Separator>
<div className='text-center text-muted mb-2'>
we'll use whichever setting is higher during{' '}
<Link
target='_blank'
href='https://docs.lightning.engineering/the-lightning-network/pathfinding'
rel='noreferrer'
>pathfinding
</Link>
</div>
<Input
label='max fee rate'
name='autoWithdrawMaxFeePercent'
hint='max fee as percent of withdrawal amount'
append={<InputGroup.Text>%</InputGroup.Text>}
required
type='number'
min={0}
/>
<Input
label='max fee total'
name='autoWithdrawMaxFeeTotal'
hint='max fee for any withdrawal amount'
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
required
type='number'
min={0}
/>
</>
)
}
function Separator ({ children, className }) {
return (
<div className={classNames(styles.separator, 'fw-bold', className)}>{children}</div>
)
}

View File

@ -782,6 +782,7 @@ div[contenteditable]:focus,
fill: var(--theme-navLink);
}
.nav-link:hover svg,
.nav-link.active svg {
fill: var(--theme-navLinkActive);
}

View File

@ -106,6 +106,12 @@
flex-direction: column;
}
@media (max-width: 768px) {
.form {
margin-top: 2rem;
}
}
.separator {
display: flex;
align-items: center;

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8 12L14 6V18L8 12Z"></path></svg>

After

Width:  |  Height:  |  Size: 123 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M10.8284 12.0007L15.7782 16.9504L14.364 18.3646L8 12.0007L14.364 5.63672L15.7782 7.05093L10.8284 12.0007Z"></path></svg>

After

Width:  |  Height:  |  Size: 209 B

View File

@ -7,7 +7,7 @@ import Link from 'next/link'
import RecvIcon from '@/svgs/arrow-left-down-line.svg'
import SendIcon from '@/svgs/arrow-right-up-line.svg'
import DragIcon from '@/svgs/draggable.svg'
import { useWalletImage, useWalletSupport, useWalletStatus, WalletStatus, useProtocolTemplates } from '@/wallets/client/hooks'
import { useWalletImage, useWalletSupport, useWalletStatus, WalletStatus } from '@/wallets/client/hooks'
import { isWallet, urlify, walletDisplayName } from '@/wallets/lib/util'
import { Draggable } from '@/wallets/client/components'
@ -55,15 +55,7 @@ export function WalletCard ({ wallet, draggable = false, index, ...props }) {
}
function WalletLink ({ wallet, children }) {
const support = useWalletSupport(wallet)
const protocols = useProtocolTemplates(wallet)
const firstSend = protocols.find(p => p.send)
const firstRecv = protocols.find(p => !p.send)
let href = '/wallets'
href += isWallet(wallet) ? `/${wallet.id}` : `/${urlify(wallet.name)}`
href += support.send ? `/send/${urlify(firstSend.name)}` : `/receive/${urlify(firstRecv.name)}`
const href = '/wallets' + (isWallet(wallet) ? `/${wallet.id}` : `/${urlify(wallet.name)}`)
return <Link href={href}>{children}</Link>
}

View File

@ -0,0 +1,20 @@
import classNames from 'classnames'
import { Button } from 'react-bootstrap'
import ArrowLeft from '@/svgs/arrow-left-s-fill.svg'
import { usePrev, useNext } from '@/components/multi-step-form'
export function BackButton ({ className }) {
const prev = usePrev()
return (
<Button className={classNames('me-3 text-muted nav-link fw-bold d-flex align-items-center', className)} variant='link' onClick={prev}>
<ArrowLeft width={24} height={24} />
back
</Button>
)
}
export function SkipButton ({ className }) {
const next = useNext()
return <Button className={classNames('ms-auto me-3 text-muted nav-link fw-bold', className)} variant='link' onClick={next}>skip</Button>
}

View File

@ -0,0 +1,136 @@
import { isTemplate, isWallet, protocolClientSchema, protocolFields, protocolFormId, walletLud16Domain } from '@/wallets/lib/util'
import { createContext, useContext, useEffect, useMemo, useCallback, useState } from 'react'
import { useWalletProtocolUpsert } from '@/wallets/client/hooks'
import { MultiStepForm, useFormState, useStep } from '@/components/multi-step-form'
export const Step = {
SEND: 'send',
RECEIVE: 'receive',
SETTINGS: 'settings'
}
const WalletMultiStepFormContext = createContext()
export function WalletMultiStepFormContextProvider ({ wallet, initial, steps, children }) {
// save selected protocol, but useProtocol will always return the first protocol if no protocol is selected
const [protocol, setProtocol] = useState(null)
const value = useMemo(() => ({ wallet, protocol, setProtocol }), [wallet, protocol, setProtocol])
return (
<WalletMultiStepFormContext.Provider value={value}>
<MultiStepForm initial={initial} steps={steps}>
{children}
</MultiStepForm>
</WalletMultiStepFormContext.Provider>
)
}
export function useWallet () {
const { wallet } = useContext(WalletMultiStepFormContext)
return wallet
}
export function useWalletProtocols () {
const step = useStep()
const wallet = useWallet()
const protocolFilter = useCallback(p => step === Step.SEND ? p.send : !p.send, [step])
return useMemo(() => {
// all protocols are templates if wallet is a template
if (isTemplate(wallet)) {
return wallet.protocols.filter(protocolFilter)
}
// return template for every protocol that isn't configured
const configured = wallet.protocols.filter(protocolFilter)
const templates = wallet.template.protocols.filter(protocolFilter)
return templates.map(p => configured.find(c => c.name === p.name) ?? p)
}, [wallet, protocolFilter])
}
export function useProtocol () {
const { protocol, setProtocol } = useContext(WalletMultiStepFormContext)
const protocols = useWalletProtocols()
useEffect(() => {
// when we move between send and receive, we need to make sure that we've selected a protocol
// that actually exists, so if the protocol is not found, we set it to the first protocol
if (!protocol || !protocols.find(p => p.id === protocol.id)) {
setProtocol(protocols[0])
}
}, [protocol, protocols, setProtocol])
// make sure we always have a protocol, even on first render before useEffect runs
return useMemo(() => [protocol ?? protocols[0], setProtocol], [protocol, protocols, setProtocol])
}
function useProtocolFormState (protocol) {
const formId = protocolFormId(protocol)
const [formState, setFormState] = useFormState(formId)
const setProtocolFormState = useCallback(
({ enabled, ...config }) => {
setFormState({ ...protocol, enabled, config })
},
[setFormState, protocol])
return useMemo(() => [formState, setProtocolFormState], [formState, setProtocolFormState])
}
export function useProtocolForm (protocol) {
const [formState, setFormState] = useProtocolFormState(protocol)
const [complementaryFormState] = useProtocolFormState({ name: protocol.name, send: !protocol.send })
const wallet = useWallet()
const lud16Domain = walletLud16Domain(wallet.name)
const fields = protocolFields(protocol)
const initial = fields.reduce((acc, field) => {
// we only fallback to the existing protocol config because formState was not initialized yet on first render
// after init, we use formState as the source of truth everywhere
let value = formState?.config?.[field.name] ?? protocol.config?.[field.name]
if (!value && field.share) {
value = complementaryFormState?.config?.[field.name]
}
if (field.name === 'address' && lud16Domain && value) {
value = value.split('@')[0]
}
return {
...acc,
[field.name]: value || ''
}
}, { enabled: formState?.enabled ?? protocol.enabled })
let schema = protocolClientSchema(protocol)
if (lud16Domain) {
schema = schema.transform(({ address, ...rest }) => {
return {
address: address ? `${address}@${lud16Domain}` : '',
...rest
}
})
}
return useMemo(() => [{ fields, initial, schema }, setFormState], [fields, initial, schema, setFormState])
}
export function useSaveWallet () {
const wallet = useWallet()
const [formState] = useFormState()
const upsert = useWalletProtocolUpsert()
const save = useCallback(async () => {
let walletId = isWallet(wallet) ? wallet.id : undefined
for (const protocol of Object.values(formState)) {
const { id } = await upsert(
{
...wallet,
id: walletId,
__typename: walletId ? 'Wallet' : 'WalletTemplate'
},
protocol, { ...protocol.config, enabled: protocol.enabled }
)
walletId ??= id
}
}, [wallet, formState, upsert])
return save
}

View File

@ -0,0 +1,231 @@
import { useCallback, useMemo } from 'react'
import { InputGroup, Nav } from 'react-bootstrap'
import classNames from 'classnames'
import styles from '@/styles/wallet.module.css'
import navStyles from '@/styles/nav.module.css'
import { Checkbox, Form, Input, PasswordInput, SubmitButton } from '@/components/form'
import CancelButton from '@/components/cancel-button'
import Text from '@/components/text'
import Info from '@/components/info'
import { useFormState, useMaxSteps, useNext, useStepIndex } from '@/components/multi-step-form'
import { isTemplate, isWallet, protocolDisplayName, protocolFormId, protocolLogName, walletLud16Domain } from '@/wallets/lib/util'
import { WalletLayout, WalletLayoutHeader, WalletLayoutImageOrName, WalletLogs } from '@/wallets/client/components'
import { TemplateLogsProvider, useTestSendPayment, useWalletLogger, useTestCreateInvoice, useWalletSupport } from '@/wallets/client/hooks'
import ArrowRight from '@/svgs/arrow-right-s-fill.svg'
import { WalletMultiStepFormContextProvider, Step, useWallet, useWalletProtocols, useProtocol, useProtocolForm } from './hooks'
import { Settings } from './settings'
import { BackButton, SkipButton } from './button'
export function WalletMultiStepForm ({ wallet }) {
const initial = useMemo(() => wallet.protocols
.filter(p => !isTemplate(p))
.reduce((acc, p) => {
const formId = protocolFormId(p)
return {
...acc,
[formId]: p
}
}, {}), [wallet])
const support = useWalletSupport(wallet)
const steps = useMemo(() =>
[
support.send && Step.SEND,
support.receive && Step.RECEIVE,
Step.SETTINGS
].filter(Boolean),
[support])
return (
<WalletLayout>
<div className={styles.form}>
<WalletLayoutHeader>
<WalletLayoutImageOrName name={wallet.name} maxHeight='80px' />
</WalletLayoutHeader>
<WalletMultiStepFormContextProvider wallet={wallet} initial={initial} steps={steps}>
{steps.map(step => {
// WalletForm is aware of the current step via hooks
// and can thus render a different form for send vs. receive
if (step === Step.SEND) return <WalletForm key={step} />
if (step === Step.RECEIVE) return <WalletForm key={step} />
return <Settings key={step} />
})}
</WalletMultiStepFormContextProvider>
</div>
</WalletLayout>
)
}
function WalletForm () {
return (
<TemplateLogsProvider>
<WalletProtocolSelector />
<WalletProtocolForm />
</TemplateLogsProvider>
)
}
function WalletProtocolSelector () {
const protocols = useWalletProtocols()
const [protocol, selectProtocol] = useProtocol()
return (
<Nav className={classNames(navStyles.nav, 'mt-0')} activeKey={protocol?.name}>
{
protocols.map(p => {
return (
<Nav.Item key={p.id} onClick={() => selectProtocol(p)}>
<Nav.Link eventKey={p.name}>
{protocolDisplayName(p)}
</Nav.Link>
</Nav.Item>
)
})
}
</Nav>
)
}
function WalletProtocolForm () {
const wallet = useWallet()
const [protocol] = useProtocol()
const next = useNext()
const testSendPayment = useTestSendPayment(protocol)
const testCreateInvoice = useTestCreateInvoice(protocol)
const logger = useWalletLogger(protocol)
const [{ fields, initial, schema }, setFormState] = useProtocolForm(protocol)
// create a copy of values to avoid mutating the original
const onSubmit = useCallback(async ({ ...values }) => {
const lud16Domain = walletLud16Domain(wallet.name)
if (values.address && lud16Domain) {
values.address = `${values.address}@${lud16Domain}`
}
const name = protocolLogName(protocol)
if (isTemplate(protocol)) {
values.enabled = true
}
if (values.enabled) {
try {
if (protocol.send) {
logger.info(`testing ${name} send ...`)
const additionalValues = await testSendPayment(values)
values = { ...values, ...additionalValues }
logger.ok(`${name} send ok`)
} else {
logger.info(`testing ${name} receive ...`)
await testCreateInvoice(values)
logger.ok(`${name} receive ok`)
}
} catch (err) {
logger.error(err.message)
throw err
}
}
setFormState(values)
next()
}, [protocol, wallet, setFormState, testSendPayment, logger, next])
return (
<>
<Form
key={`form-${protocol.id}`}
enableReinitialize
initial={initial}
schema={schema}
onSubmit={onSubmit}
>
{fields.map(field => <WalletProtocolFormField key={field.name} {...field} />)}
{!isTemplate(protocol) && <Checkbox name='enabled' label='enabled' />}
<WalletProtocolFormNavigator />
</Form>
<WalletLogs className='mt-3' protocol={protocol} key={`logs-${protocol.id}`} />
</>
)
}
function WalletProtocolFormNavigator () {
const wallet = useWallet()
const stepIndex = useStepIndex()
const maxSteps = useMaxSteps()
const [formState] = useFormState()
// was something already configured or was something configured just now?
const configExists = (isWallet(wallet) && wallet.protocols.length > 0) || Object.keys(formState).length > 0
// don't allow going to settings as last step with nothing configured
const hideSkip = stepIndex === maxSteps - 2 && !configExists
return (
<div className='d-flex justify-content-end align-items-center'>
{stepIndex === 0 ? <CancelButton>cancel</CancelButton> : <BackButton />}
{!hideSkip ? <SkipButton /> : <div className='ms-auto' />}
<SubmitButton variant='primary' className='ps-3 pe-2 d-flex align-items-center'>
next
<ArrowRight width={24} height={24} />
</SubmitButton>
</div>
)
}
function WalletProtocolFormField ({ type, ...props }) {
const wallet = useWallet()
const [protocol] = useProtocol()
function transform ({ validate, encrypt, editable, help, share, ...props }) {
const [upperHint, bottomHint] = Array.isArray(props.hint) ? props.hint : [null, props.hint]
const parseHelpText = text => Array.isArray(text) ? text.join('\n\n') : text
const _help = help
? (
typeof help === 'string'
? { label: null, text: help }
: (
Array.isArray(help)
? { label: null, text: parseHelpText(help) }
: { label: help.label, text: parseHelpText(help.text) }
)
)
: null
const readOnly = !!protocol.config?.[props.name] && editable === false
const label = (
<div className='d-flex align-items-center'>
{props.label}
{_help && (
<Info label={_help.label}>
<Text>{_help.text}</Text>
</Info>
)}
<small className='text-muted ms-2'>
{upperHint
? <Text>{upperHint}</Text>
: (!props.required ? 'optional' : null)}
</small>
</div>
)
return { ...props, hint: bottomHint, label, readOnly }
}
switch (type) {
case 'text': {
let append
const lud16Domain = walletLud16Domain(wallet.name)
if (props.name === 'address' && lud16Domain) {
append = <InputGroup.Text className='text-monospace'>@{lud16Domain}</InputGroup.Text>
}
return <Input {...transform(props)} append={append} />
}
case 'password':
return <PasswordInput {...transform(props)} />
default:
return null
}
}

View File

@ -0,0 +1,280 @@
import { useCallback } from 'react'
import Button from 'react-bootstrap/Button'
import InputGroup from 'react-bootstrap/InputGroup'
import { useField } from 'formik'
import classNames from 'classnames'
import { useRouter } from 'next/router'
import { useMutation, useQuery } from '@apollo/client'
import { Checkbox, Form, Input, SubmitButton } from '@/components/form'
import Info from '@/components/info'
import { useToast } from '@/components/toast'
import AccordianItem from '@/components/accordian-item'
import { isNumber } from '@/lib/format'
import { walletSettingsSchema } from '@/lib/validate'
import styles from '@/styles/wallet.module.css'
import { useShowModal } from '@/components/modal'
import { SET_WALLET_SETTINGS, WALLET_SETTINGS } from '@/wallets/client/fragments'
import { useWalletDelete } from '@/wallets/client/hooks'
import { useSaveWallet, useWallet } from './hooks'
import { BackButton } from './button'
import { isWallet } from '@/wallets/lib/util'
export function Settings () {
const wallet = useWallet()
const { data } = useQuery(WALLET_SETTINGS)
const [setSettings] = useMutation(SET_WALLET_SETTINGS)
const toaster = useToast()
const saveWallet = useSaveWallet()
const router = useRouter()
const onSubmit = useCallback(async (settings) => {
try {
await saveWallet()
await setSettings({
variables: { settings },
update: (cache, { data }) => {
cache.writeQuery({
query: WALLET_SETTINGS,
data: {
walletSettings: {
__typename: 'WalletSettings',
...data?.setWalletSettings
}
}
})
}
})
router.push('/wallets')
} catch (err) {
console.error(err)
toaster.danger('failed to save wallet')
}
}, [saveWallet, setSettings, toaster, router])
const initial = {
receiveCreditsBelowSats: data?.walletSettings?.receiveCreditsBelowSats ?? 10,
sendCreditsBelowSats: data?.walletSettings?.sendCreditsBelowSats ?? 10,
autoWithdrawThreshold: data?.walletSettings?.autoWithdrawThreshold ?? 10000,
autoWithdrawMaxFeePercent: data?.walletSettings?.autoWithdrawMaxFeePercent ?? 1,
autoWithdrawMaxFeeTotal: data?.walletSettings?.autoWithdrawMaxFeeTotal ?? 1,
proxyReceive: data?.walletSettings?.proxyReceive ?? true
}
return (
<>
<Form
enableReinitialize
initial={initial}
schema={walletSettingsSchema}
onSubmit={onSubmit}
>
<GlobalSettings />
<div className='d-flex mt-5 justify-content-end align-items-center'>
<BackButton className='me-auto' />
{isWallet(wallet) && <WalletDeleteButton className='me-2' />}
<SubmitButton variant='primary'>save</SubmitButton>
</div>
</Form>
</>
)
}
function Separator ({ children, className }) {
return (
<div className={classNames(styles.separator, 'fw-bold', className)}>{children}</div>
)
}
function WalletDeleteButton ({ className }) {
const showModal = useShowModal()
const wallet = useWallet()
return (
<Button
variant='danger'
className={className}
onClick={() => {
showModal(onClose => {
// need to pass wallet as prop because the modal can't use the hooks
// since it's not rendered as a children of the form
return <WalletDeleteObstacle wallet={wallet} onClose={onClose} />
})
}}
>delete
</Button>
)
}
function WalletDeleteObstacle ({ wallet, onClose }) {
const deleteWallet = useWalletDelete(wallet)
const toaster = useToast()
const router = useRouter()
const onClick = useCallback(async () => {
try {
await deleteWallet()
onClose()
router.push('/wallets')
} catch (err) {
console.error('failed to delete wallet:', err)
toaster.danger('failed to delete wallet')
}
}, [deleteWallet, onClose, toaster, router])
return (
<div>
<h4>Delete wallet</h4>
<p className='line-height-md fw-bold mt-3'>
Are you sure you want to delete this wallet?
</p>
<div className='mt-3 d-flex justify-content-end align-items-center'>
<Button className='me-3 text-muted nav-link fw-bold' variant='link' onClick={onClose}>cancel</Button>
<Button variant='danger' onClick={onClick}>delete </Button>
</div>
</div>
)
}
function GlobalSettings () {
return (
<>
<Separator>global settings</Separator>
<AutowithdrawSettings />
<AccordianItem
header='advanced'
body={
<>
<LightningAddressSettings />
<CowboyCreditsSettings />
</>
}
/>
</>
)
}
function AutowithdrawSettings () {
const [{ value: threshold }] = useField('autoWithdrawThreshold')
const sendThreshold = Math.max(Math.floor(threshold / 10), 1)
return (
<>
<Input
label='desired balance'
name='autoWithdrawThreshold'
hint={isNumber(sendThreshold) ? `will attempt autowithdrawal when your balance exceeds ${sendThreshold * 11} sats` : undefined}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
required
type='number'
min={0}
groupClassName='mb-2'
/>
<Input
label={
<div className='d-flex align-items-center'>
max fee rate
<Info>
<ul>
<li>configure fee budget for autowithdrawals</li>
<li>if max fee total is higher for a withdrawal, we will use it instead to find a route</li>
<li>higher fee settings increase the likelihood of successful withdrawals</li>
</ul>
</Info>
</div>
}
name='autoWithdrawMaxFeePercent'
append={<InputGroup.Text>%</InputGroup.Text>}
required
type='number'
min={0}
/>
<Input
label={
<div className='d-flex align-items-center'>
max fee total
<Info>
<ul>
<li>configure fee budget for autowithdrawals</li>
<li>if max fee rate is higher for a withdrawal, we will use it instead to find a route to your wallet</li>
<li>higher fee settings increase the likelihood of successful withdrawals</li>
</ul>
</Info>
</div>
}
name='autoWithdrawMaxFeeTotal'
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
required
type='number'
min={0}
/>
</>
)
}
function LightningAddressSettings () {
return (
<>
<Checkbox
label={
<div className='d-flex align-items-center'>enhance privacy of my lightning address
<Info>
<ul>
<li>Enabling this setting hides details (ie node pubkey) of your attached wallets when anyone pays your SN lightning address or lnurl-pay</li>
<li>The lightning invoice will appear to have SN's node as the destination to preserve your wallet's privacy</li>
<li className='fw-bold'>This will incur in a 10% fee</li>
<li>Disable this setting to receive payments directly to your attached wallets (which will reveal their details to the payer)</li>
<li>Note: this privacy behavior is standard for internal zaps/payments on SN, and this setting only applies to external payments</li>
</ul>
</Info>
</div>
}
name='proxyReceive'
groupClassName='mb-3'
/>
</>
)
}
function CowboyCreditsSettings () {
return (
<>
<Input
label={
<div className='d-flex align-items-center'>
receive credits for zaps below
<Info>
<ul>
<li>we will not attempt to forward zaps below this amount to you, you will receive credits instead</li>
<li>this setting is useful if small amounts are expensive to receive for you</li>
</ul>
</Info>
</div>
}
name='receiveCreditsBelowSats'
required
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
type='number'
min={0}
/>
<Input
label={
<div className='d-flex align-items-center'>
send credits for zaps below
<Info>
<ul>
<li>we will not attempt to send zaps below this amount from your wallet if you have enough credits</li>
<li>this setting is useful if small amounts are expensive to send for you</li>
</ul>
</Info>
</div>
}
name='sendCreditsBelowSats'
required
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
type='number'
min={0}
/>
</>
)
}

View File

@ -1,345 +0,0 @@
import { useCallback, useMemo, createContext, useContext } from 'react'
import { Button, InputGroup, Nav } from 'react-bootstrap'
import Link from 'next/link'
import { useParams, usePathname } from 'next/navigation'
import { useRouter } from 'next/router'
import { WalletLayout, WalletLayoutHeader, WalletLayoutImageOrName, WalletLogs } from '@/wallets/client/components'
import { protocolDisplayName, protocolFields, protocolClientSchema, unurlify, urlify, isWallet, isTemplate, walletLud16Domain } from '@/wallets/lib/util'
import styles from '@/styles/wallet.module.css'
import navStyles from '@/styles/nav.module.css'
import { Checkbox, Form, Input, PasswordInput, SubmitButton } from '@/components/form'
import CancelButton from '@/components/cancel-button'
import { useWalletProtocolUpsert, useWalletProtocolRemove, useProtocolTemplates, TemplateLogsProvider } from '@/wallets/client/hooks'
import { useToast } from '@/components/toast'
import Text from '@/components/text'
import Info from '@/components/info'
import classNames from 'classnames'
const WalletFormsContext = createContext()
export function WalletForms ({ wallet, refetch }) {
return (
<WalletLayout>
<div className={styles.form}>
<WalletLayoutHeader>
{wallet && <WalletLayoutImageOrName name={wallet.name} maxHeight='80px' />}
</WalletLayoutHeader>
{wallet && (
<WalletFormsProvider wallet={wallet} refetch={refetch}>
<WalletFormSelector />
</WalletFormsProvider>
)}
</div>
</WalletLayout>
)
}
function WalletFormsProvider ({ children, wallet, refetch }) {
const value = useMemo(() => ({ refetch, wallet }), [refetch, wallet])
return (
<WalletFormsContext.Provider value={value}>
{children}
</WalletFormsContext.Provider>
)
}
function useWalletRefetch () {
const { refetch } = useContext(WalletFormsContext)
return refetch
}
function useWallet () {
const { wallet } = useContext(WalletFormsContext)
return wallet
}
function WalletFormSelector () {
const sendRecvParam = useSendRecvParam()
const protocolParam = useWalletProtocolParam()
return (
<>
<WalletSendRecvSelector />
{sendRecvParam && (
<div className='position-relative'>
<div>
<WalletProtocolSelector />
{protocolParam && (
<TemplateLogsProvider>
<WalletProtocolForm />
</TemplateLogsProvider>
)}
</div>
</div>
)}
</>
)
}
function WalletSendRecvSelector () {
const wallet = useWallet()
const path = useWalletPathname()
const protocols = useProtocolTemplates(wallet)
const selected = useSendRecvParam()
const firstSend = protocols.find(p => p.send)
const firstRecv = protocols.find(p => !p.send)
// TODO(wallet-v2): if you click a nav link again, it will update the URL
// but not run the effect again to select the first protocol by default
return (
<Nav
key={path}
className={classNames(navStyles.nav, 'justify-content-center')}
activeKey={selected}
>
<Nav.Item>
<Link href={`/${path}/send${firstSend ? `/${urlify(firstSend.name)}` : ''}`} passHref legacyBehavior replace>
<Nav.Link className='ps-3' eventKey='send'>SEND</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item>
<Link href={`/${path}/receive${firstRecv ? `/${urlify(firstRecv.name)}` : ''}`} passHref legacyBehavior replace>
<Nav.Link className='ps-3' eventKey='receive'>RECEIVE</Nav.Link>
</Link>
</Nav.Item>
</Nav>
)
}
function WalletProtocolSelector () {
const walletPath = useWalletPathname()
const sendRecvParam = useSendRecvParam()
const selected = useWalletProtocolParam()
const path = `${walletPath}/${sendRecvParam}`
const wallet = useWallet()
const protocols = useProtocolTemplates(wallet).filter(p => sendRecvParam === 'send' ? p.send : !p.send)
if (protocols.length === 0) {
// TODO(wallet-v2): let user know how to request support if the wallet actually does support sending
return (
<div className='mt-3 text-muted text-center'>
{sendRecvParam === 'send' ? 'sending' : 'receiving'} not supported
</div>
)
}
return (
<Nav
key={path}
className={classNames(navStyles.nav, 'justify-content-start mt-0')}
activeKey={selected}
>
{
protocols.map(p => (
<Nav.Item key={p.name}>
<Link href={`/${path}/${urlify(p.name)}`} passHref legacyBehavior replace>
<Nav.Link eventKey={p.name}>{protocolDisplayName(p)}</Nav.Link>
</Link>
</Nav.Item>
))
}
</Nav>
)
}
function WalletProtocolForm () {
const sendRecvParam = useSendRecvParam()
const router = useRouter()
const protocol = useSelectedProtocol()
if (!protocol) return null
// I think it is okay to skip this hook if the protocol is not found
// because we will need to change the URL to get a different protocol
// so the amount of rendered hooks should stay the same during the lifecycle of this component
const wallet = useWallet()
const upsertWalletProtocol = useWalletProtocolUpsert(wallet, protocol)
const toaster = useToast()
const refetch = useWalletRefetch()
const { fields, initial, schema } = useProtocolForm(protocol)
// create a copy of values to avoid mutating the original
const onSubmit = useCallback(async ({ ...values }) => {
const lud16Domain = walletLud16Domain(wallet.name)
if (values.address && lud16Domain) {
values.address = `${values.address}@${lud16Domain}`
}
const upsert = await upsertWalletProtocol(values)
if (isWallet(wallet)) {
toaster.success('wallet saved')
refetch()
return
}
// we just created a new user wallet from a template
router.replace(`/wallets/${upsert.id}/${sendRecvParam}/${urlify(protocol.name)}`, null, { shallow: true })
toaster.success('wallet attached', { persistOnNavigate: true })
}, [upsertWalletProtocol, toaster, wallet, router])
return (
<>
<Form
key={router.asPath}
enableReinitialize
initial={initial}
schema={schema}
onSubmit={onSubmit}
>
{fields.map(field => <WalletProtocolFormField key={field.name} {...field} />)}
<Checkbox name='enabled' label='enabled' disabled={isTemplate(protocol)} />
<WalletProtocolFormButtons />
</Form>
<WalletLogs className='mt-3' protocol={protocol} />
</>
)
}
function WalletProtocolFormButtons () {
const protocol = useSelectedProtocol()
const removeWalletProtocol = useWalletProtocolRemove(protocol)
const refetch = useWalletRefetch()
const router = useRouter()
const wallet = useWallet()
const isLastProtocol = wallet.protocols.length === 1
const onDetach = useCallback(async () => {
await removeWalletProtocol()
if (isLastProtocol) {
router.replace('/wallets', null, { shallow: true })
return
}
refetch()
}, [removeWalletProtocol, refetch, isLastProtocol, router])
return (
<div className='d-flex justify-content-end'>
{!isTemplate(protocol) && <Button variant='grey-medium' className='me-auto' onClick={onDetach}>detach</Button>}
<CancelButton>cancel</CancelButton>
<SubmitButton variant='primary'>{isWallet(wallet) ? 'save' : 'attach'}</SubmitButton>
</div>
)
}
function WalletProtocolFormField ({ type, ...props }) {
const wallet = useWallet()
const protocol = useSelectedProtocol()
function transform ({ validate, encrypt, editable, help, ...props }) {
const [upperHint, bottomHint] = Array.isArray(props.hint) ? props.hint : [null, props.hint]
const parseHelpText = text => Array.isArray(text) ? text.join('\n\n') : text
const _help = help
? (
typeof help === 'string'
? { label: null, text: help }
: (
Array.isArray(help)
? { label: null, text: parseHelpText(help) }
: { label: help.label, text: parseHelpText(help.text) }
)
)
: null
const readOnly = !!protocol.config?.[props.name] && editable === false
const label = (
<div className='d-flex align-items-center'>
{props.label}
{_help && (
<Info label={_help.label}>
<Text>{_help.text}</Text>
</Info>
)}
<small className='text-muted ms-2'>
{upperHint
? <Text>{upperHint}</Text>
: (!props.required ? 'optional' : null)}
</small>
</div>
)
return { ...props, hint: bottomHint, label, readOnly }
}
switch (type) {
case 'text': {
let append
const lud16Domain = walletLud16Domain(wallet.name)
if (props.name === 'address' && lud16Domain) {
append = <InputGroup.Text className='text-monospace'>@{lud16Domain}</InputGroup.Text>
}
return <Input {...transform(props)} append={append} />
}
case 'password':
return <PasswordInput {...transform(props)} />
default:
return null
}
}
function useWalletPathname () {
const pathname = usePathname()
// returns /wallets/:name
return pathname.split('/').filter(Boolean).slice(0, 2).join('/')
}
function useSendRecvParam () {
const params = useParams()
// returns only :send in /wallets/:name/:send
return ['send', 'receive'].includes(params.slug[1]) ? params.slug[1] : null
}
function useWalletProtocolParam () {
const params = useParams()
const name = params.slug[2]
// returns only :protocol in /wallets/:name/:send/:protocol
return name ? unurlify(name) : null
}
function useSelectedProtocol () {
const wallet = useWallet()
const sendRecvParam = useSendRecvParam()
const protocolParam = useWalletProtocolParam()
const send = sendRecvParam === 'send'
let protocol = wallet.protocols.find(p => p.name === protocolParam && p.send === send)
if (!protocol && isWallet(wallet)) {
// the protocol was not found as configured, look for it in the template
protocol = wallet.template.protocols.find(p => p.name === protocolParam && p.send === send)
}
return protocol
}
function useProtocolForm (protocol) {
const wallet = useWallet()
const lud16Domain = walletLud16Domain(wallet.name)
const fields = protocolFields(protocol)
const initial = fields.reduce((acc, field) => {
// wallet templates don't have a config
let value = protocol.config?.[field.name]
if (field.name === 'address' && lud16Domain && value) {
value = value.split('@')[0]
}
return {
...acc,
[field.name]: value || ''
}
}, { enabled: protocol.enabled })
let schema = protocolClientSchema(protocol)
if (lud16Domain) {
schema = schema.transform(({ address, ...rest }) => {
return {
address: address ? `${address}@${lud16Domain}` : '',
...rest
}
})
}
return { fields, initial, schema }
}

View File

@ -1,6 +1,6 @@
export * from './card'
export * from './draggable'
export * from './forms'
export * from './form/index'
export * from './layout'
export * from './passphrase'
export * from './logger'

View File

@ -203,6 +203,11 @@ export const RESET_WALLETS = gql`
}
`
export const DELETE_WALLET = gql`
mutation deleteWallet($id: ID!) {
deleteWallet(id: $id)
}`
export const DISABLE_PASSPHRASE_EXPORT = gql`
mutation DisablePassphraseExport {
disablePassphraseExport
@ -224,7 +229,14 @@ export const WALLET_SETTINGS = gql`
export const SET_WALLET_SETTINGS = gql`
mutation SetWalletSettings($settings: WalletSettingsInput!) {
setWalletSettings(settings: $settings)
setWalletSettings(settings: $settings) {
receiveCreditsBelowSats
sendCreditsBelowSats
proxyReceive
autoWithdrawMaxFeePercent
autoWithdrawMaxFeeTotal
autoWithdrawThreshold
}
}
`

View File

@ -13,7 +13,6 @@ import {
UPSERT_WALLET_SEND_PHOENIXD,
UPSERT_WALLET_SEND_WEBLN,
WALLETS,
REMOVE_WALLET_PROTOCOL,
UPDATE_WALLET_ENCRYPTION,
RESET_WALLETS,
DISABLE_PASSPHRASE_EXPORT,
@ -25,13 +24,14 @@ import {
TEST_WALLET_RECEIVE_LIGHTNING_ADDRESS,
TEST_WALLET_RECEIVE_NWC,
TEST_WALLET_RECEIVE_CLN_REST,
TEST_WALLET_RECEIVE_LND_GRPC
TEST_WALLET_RECEIVE_LND_GRPC,
DELETE_WALLET
} from '@/wallets/client/fragments'
import { gql, useApolloClient, useMutation, useQuery } from '@apollo/client'
import { useDecryption, useEncryption, useSetKey, useWalletLogger, useWalletsUpdatedAt, WalletStatus } from '@/wallets/client/hooks'
import { useDecryption, useEncryption, useSetKey, useWalletLoggerFactory, useWalletsUpdatedAt, WalletStatus } from '@/wallets/client/hooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
isEncryptedField, isTemplate, isWallet, protocolAvailable, protocolClientSchema, reverseProtocolRelationName
isEncryptedField, isTemplate, isWallet, protocolAvailable, protocolClientSchema, protocolLogName, reverseProtocolRelationName
} from '@/wallets/lib/util'
import { protocolTestSendPayment } from '@/wallets/client/protocols'
import { timeoutSignal } from '@/lib/time'
@ -146,37 +146,19 @@ function server2Client (wallet) {
return wallet ? undoFieldAlias(checkProtocolAvailability(wallet)) : wallet
}
export function useWalletProtocolUpsert (wallet, protocol) {
const mutation = getWalletProtocolUpsertMutation(protocol)
const [mutate] = useMutation(mutation)
const { encryptConfig } = useEncryptConfig(protocol)
const testSendPayment = useTestSendPayment(protocol)
const testCreateInvoice = useTestCreateInvoice(protocol)
const logger = useWalletLogger(protocol)
export function useWalletProtocolUpsert () {
const client = useApolloClient()
const loggerFactory = useWalletLoggerFactory()
const { encryptConfig } = useEncryptConfig()
return useCallback(async (values) => {
logger.info('saving wallet ...')
return useCallback(async (wallet, protocol, values) => {
const logger = loggerFactory(protocol)
const mutation = protocolUpsertMutation(protocol)
const name = `${protocolLogName(protocol)} ${protocol.send ? 'send' : 'receive'}`
if (isTemplate(protocol)) {
values.enabled = true
}
logger.info(`saving ${name} ...`)
// skip network tests if we're disabling the wallet
if (values.enabled) {
try {
if (protocol.send) {
const additionalValues = await testSendPayment(values)
values = { ...values, ...additionalValues }
} else {
await testCreateInvoice(values)
}
} catch (err) {
logger.error(err.message)
throw err
}
}
const encrypted = await encryptConfig(values)
const encrypted = await encryptConfig(values, { protocol })
const variables = encrypted
if (isWallet(wallet)) {
@ -187,8 +169,8 @@ export function useWalletProtocolUpsert (wallet, protocol) {
let updatedWallet
try {
const { data } = await mutate({ variables })
logger.ok('wallet saved')
const { data } = await client.mutate({ mutation, variables })
logger.ok(`${name} saved`)
updatedWallet = Object.values(data)[0]
} catch (err) {
logger.error(err.message)
@ -198,29 +180,20 @@ export function useWalletProtocolUpsert (wallet, protocol) {
requestPersistentStorage()
return updatedWallet
}, [wallet, protocol, logger, testSendPayment, testCreateInvoice, encryptConfig, mutate])
}, [client, loggerFactory, encryptConfig])
}
export function useLightningAddressUpsert () {
// TODO(wallet-v2): parse domain from address input to use correct wallet template
// useWalletProtocolUpsert needs to support passing in the wallet in the callback for that
const wallet = { name: 'LN_ADDR', __typename: 'WalletTemplate' }
const protocol = { name: 'LN_ADDR', send: false, __typename: 'WalletProtocolTemplate' }
return useWalletProtocolUpsert(wallet, protocol)
}
const wallet = useMemo(() => ({ name: 'LN_ADDR', __typename: 'WalletTemplate' }), [])
const protocol = useMemo(() => ({ name: 'LN_ADDR', send: false, __typename: 'WalletProtocolTemplate' }), [])
const upsert = useWalletProtocolUpsert()
const testCreateInvoice = useTestCreateInvoice(protocol)
export function useWalletProtocolRemove (protocol) {
const [mutate] = useMutation(REMOVE_WALLET_PROTOCOL)
const toaster = useToast()
return useCallback(async () => {
try {
await mutate({ variables: { id: protocol.id } })
toaster.success('protocol detached')
} catch (err) {
toaster.danger('failed to detach protocol: ' + err.message)
}
}, [protocol?.id, mutate, toaster])
return useCallback(async (values) => {
// TODO(wallet-v2): parse domain from address input to use correct wallet template
await testCreateInvoice(values)
return await upsert(wallet, protocol, { ...values, enabled: true })
}, [testCreateInvoice, upsert, wallet, protocol])
}
export function useWalletEncryptionUpdate () {
@ -295,7 +268,7 @@ export function useSetWalletPriorities () {
// (the mutation would throw if called but we make sure to never call it.)
const NOOP_MUTATION = gql`mutation noop { noop }`
function getWalletProtocolUpsertMutation (protocol) {
function protocolUpsertMutation (protocol) {
switch (protocol.name) {
case 'LNBITS':
return protocol.send ? UPSERT_WALLET_SEND_LNBITS : UPSERT_WALLET_RECEIVE_LNBITS
@ -320,7 +293,7 @@ function getWalletProtocolUpsertMutation (protocol) {
}
}
function getWalletProtocolTestMutation (protocol) {
function protocolTestMutation (protocol) {
if (protocol.send) return NOOP_MUTATION
switch (protocol.name) {
@ -343,7 +316,7 @@ function getWalletProtocolTestMutation (protocol) {
}
}
function useTestSendPayment (protocol) {
export function useTestSendPayment (protocol) {
return useCallback(async (values) => {
return await protocolTestSendPayment(
protocol,
@ -353,8 +326,8 @@ function useTestSendPayment (protocol) {
}, [protocol])
}
function useTestCreateInvoice (protocol) {
const mutation = getWalletProtocolTestMutation(protocol)
export function useTestCreateInvoice (protocol) {
const mutation = protocolTestMutation(protocol)
const [testCreateInvoice] = useMutation(mutation)
return useCallback(async (values) => {
@ -362,6 +335,14 @@ function useTestCreateInvoice (protocol) {
}, [testCreateInvoice])
}
export function useWalletDelete (wallet) {
const [mutate] = useMutation(DELETE_WALLET)
return useCallback(async () => {
await mutate({ variables: { id: wallet.id } })
}, [mutate, wallet.id])
}
function useWalletDecryption () {
const { decryptConfig, ready } = useDecryptConfig()
@ -491,7 +472,7 @@ export function useWalletMigrationMutation () {
}
await client.mutate({
mutation: getWalletProtocolUpsertMutation(protocol),
mutation: protocolUpsertMutation(protocol),
variables: {
...(walletId ? { walletId } : { templateName }),
enabled,

View File

@ -32,6 +32,7 @@ import webln from './webln'
* @property {yup.Schema} validate - validation rules to apply
* @property {string} [placeholder] - placeholder text shown in input field
* @property {string} [hint] - hint text shown below field
* @property {boolean} [share] - whether field can be used to prepopulate field of complementary send/receive protocol
*/
/** @type {Protocol[]} */

View File

@ -14,7 +14,8 @@ export default [
label: 'url',
type: 'text',
validate: urlValidator('clearnet', 'tor'),
required: true
required: true,
share: true
},
{
name: 'apiKey',
@ -37,7 +38,8 @@ export default [
label: 'url',
type: 'text',
validate: urlValidator('clearnet', 'tor'),
required: true
required: true,
share: true
},
{
name: 'apiKey',

View File

@ -10,6 +10,7 @@ export default [
name: 'NWC',
send: true,
displayName: 'Nostr Wallet Connect',
logName: 'NWC',
fields: [
{
name: 'url',
@ -27,6 +28,7 @@ export default [
name: 'NWC',
send: false,
displayName: 'Nostr Wallet Connect',
logName: 'NWC',
fields: [
{
name: 'url',

View File

@ -14,7 +14,8 @@ export default [
type: 'text',
label: 'url',
validate: urlValidator('clearnet'),
required: true
required: true,
share: true
},
{
name: 'apiKey',
@ -42,7 +43,8 @@ export default [
type: 'text',
label: 'url',
validate: urlValidator('clearnet'),
required: true
required: true,
share: true
},
{
name: 'apiKey',

View File

@ -30,6 +30,10 @@ export function protocolDisplayName ({ name, send }) {
return protocol({ name, send })?.displayName || titleCase(name)
}
export function protocolLogName ({ name, send }) {
return protocol({ name, send })?.logName ?? protocolDisplayName({ name, send })
}
export function protocolRelationName ({ name, send }) {
return protocol({ name, send })?.relationName
}
@ -120,3 +124,10 @@ export function isWallet (wallet) {
export function isTemplate (obj) {
return obj.__typename.endsWith('Template')
}
export function protocolFormId ({ name, send }) {
// we don't use the protocol id as the form id because then we can't find the
// complementary protocol to share fields between templates and non-templates
// by simply flipping send to recv and vice versa
return `${name}-${send ? 'send' : 'recv'}`
}

View File

@ -62,12 +62,11 @@ export const resolvers = {
}, [])
),
addWalletLog,
removeWalletProtocol,
deleteWalletLogs
}
}
function testWalletProtocol (protocol) {
export function testWalletProtocol (protocol) {
return async (parent, args, { me, models, tx }) => {
if (!me) {
throw new GqlAuthenticationError()
@ -223,6 +222,7 @@ export function upsertWalletProtocol (protocol) {
}
}
// not exposed to the client via GraphQL API, but used when resetting wallets
export async function removeWalletProtocol (parent, { id }, { me, models, tx }) {
if (!me) {
throw new GqlAuthenticationError()
@ -336,7 +336,7 @@ async function deleteWalletLogs (parent, { protocolId, debug }, { me, models })
return true
}
async function updateWalletBadges ({ userId, tx }) {
export async function updateWalletBadges ({ userId, tx }) {
const pushNotifications = []
const wallets = await tx.wallet.findMany({

View File

@ -1,6 +1,6 @@
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { mapWalletResolveTypes } from '@/wallets/server/resolvers/util'
import { removeWalletProtocol, upsertWalletProtocol } from './protocol'
import { removeWalletProtocol, upsertWalletProtocol, updateWalletBadges } from './protocol'
import { validateSchema, walletSettingsSchema } from '@/lib/validate'
const WalletOrTemplate = {
@ -47,7 +47,8 @@ export const resolvers = {
resetWallets,
setWalletPriorities,
disablePassphraseExport,
setWalletSettings
setWalletSettings,
deleteWallet
}
}
@ -168,6 +169,17 @@ async function updateKeyHash (parent, { keyHash }, { me, models }) {
return count > 0
}
async function deleteWallet (parent, { id }, { me, models }) {
if (!me) throw new GqlAuthenticationError()
await models.$transaction(async tx => {
await tx.wallet.delete({ where: { id: Number(id), userId: me.id } })
await updateWalletBadges({ userId: me.id, tx })
})
return true
}
async function resetWallets (parent, { newKeyHash }, { me, models }) {
if (!me) throw new GqlAuthenticationError()
@ -233,5 +245,5 @@ async function setWalletSettings (parent, { settings }, { me, models }) {
await models.user.update({ where: { id: me.id }, data: settings })
return true
return settings
}