diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js
index 20b327a6..d3d2cea6 100644
--- a/api/typeDefs/wallet.js
+++ b/api/typeDefs/wallet.js
@@ -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
diff --git a/components/multi-step-form.js b/components/multi-step-form.js
new file mode 100644
index 00000000..c3ad2f5b
--- /dev/null
+++ b/components/multi-step-form.js
@@ -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 (
+
+
+ {children[stepIndex]}
+
+ )
+}
+
+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 (
+
+ {
+ steps.map((label, i) => {
+ const last = i === steps.length - 1
+ return (
+
+ = i} />
+ {!last && = i + 1} />}
+
+ )
+ })
+ }
+
+ )
+}
+function ProgressNumber ({ number, label, active }) {
+ return (
+
+ )
+}
+
+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 }) => (
+
+ {children}
+
+ )
+
+ const Circle = () => {
+ const circleProps = {
+ fill: active ? 'var(--bs-info)' : 'var(--bs-body-bg)',
+ stroke: active ? 'var(--bs-info)' : 'var(--theme-grey)'
+ }
+ return (
+
+
+
+ )
+ }
+
+ 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 (
+
+
+
+ )
+ case 2:
+ return (
+
+
+
+ )
+ case 3:
+ return (
+
+
+
+ )
+ default:
+ return null
+ }
+ }
+
+ return (
+
+
+
+
+ )
+}
+
+function ProgressLine ({ style, active }) {
+ const svgStyle = { display: 'block', position: 'relative', top: `${NUMBER_SVG_HEIGHT / 2}px` }
+ return (
+
+ )
+}
+
+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])
+}
diff --git a/fragments/users.js b/fragments/users.js
index 9e42fbfb..a1fa8cf7 100644
--- a/fragments/users.js
+++ b/fragments/users.js
@@ -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)
diff --git a/pages/wallets/[...slug].js b/pages/wallets/[type].js
similarity index 74%
rename from pages/wallets/[...slug].js
rename to pages/wallets/[type].js
index d453545f..9b49b7c9 100644
--- a/pages/wallets/[...slug].js
+++ b/pages/wallets/[type].js
@@ -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
+ return
}
diff --git a/pages/wallets/index.js b/pages/wallets/index.js
index c36bbaf6..98bf14e1 100644
--- a/pages/wallets/index.js
+++ b/pages/wallets/index.js
@@ -113,8 +113,6 @@ export default function Wallet () {
use real bitcoin
wallet logs
-
•
-
settings
{showPassphrase && (
<>
•
diff --git a/pages/wallets/settings.js b/pages/wallets/settings.js
deleted file mode 100644
index 629e71a5..00000000
--- a/pages/wallets/settings.js
+++ /dev/null
@@ -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 (
-
-
-
wallet settings
-
apply globally to all wallets
-
-
-
- )
-}
-
-function CowboyCreditsSettings () {
- return (
- <>
-
cowboy credits
-
sats}
- type='number'
- min={0}
- />
-
sats}
- type='number'
- min={0}
- />
-
- >
- )
-}
-
-function LightningAddressSettings () {
- return (
- <>
-
@stacker.news lightning address
-
enhance privacy of my lightning address
-
-
- Enabling this setting hides details (ie node pubkey) of your attached wallets when anyone pays your SN lightning address or lnurl-pay
- The lightning invoice will appear to have SN's node as the destination to preserve your wallet's privacy
- This will incur in a 10% fee
- Disable this setting to receive payments directly to your attached wallets (which will reveal their details to the payer)
- Note: this privacy behavior is standard for internal zaps/payments on SN, and this setting only applies to external payments
-
-
-
- }
- name='proxyReceive'
- groupClassName='mb-0'
- />
- >
- )
-}
-
-function AutowithdrawSettings () {
- const [{ value: threshold }] = useField('autoWithdrawThreshold')
- const sendThreshold = Math.max(Math.floor(threshold / 10), 1)
-
- return (
- <>
- autowithdrawal
- sats}
- required
- type='number'
- min={0}
- />
-
- >
- )
-}
-
-function LightningNetworkFeesSettings () {
- return (
- <>
- lightning network fees
-
- we'll use whichever setting is higher during{' '}
- pathfinding
-
-
- %}
- required
- type='number'
- min={0}
- />
- sats}
- required
- type='number'
- min={0}
- />
- >
- )
-}
-
-function Separator ({ children, className }) {
- return (
- {children}
- )
-}
diff --git a/styles/globals.scss b/styles/globals.scss
index fd470920..05eaaab6 100644
--- a/styles/globals.scss
+++ b/styles/globals.scss
@@ -782,6 +782,7 @@ div[contenteditable]:focus,
fill: var(--theme-navLink);
}
+.nav-link:hover svg,
.nav-link.active svg {
fill: var(--theme-navLinkActive);
}
diff --git a/styles/wallet.module.css b/styles/wallet.module.css
index a54f5156..9464633b 100644
--- a/styles/wallet.module.css
+++ b/styles/wallet.module.css
@@ -106,6 +106,12 @@
flex-direction: column;
}
+@media (max-width: 768px) {
+ .form {
+ margin-top: 2rem;
+ }
+}
+
.separator {
display: flex;
align-items: center;
diff --git a/svgs/arrow-left-s-fill.svg b/svgs/arrow-left-s-fill.svg
new file mode 100644
index 00000000..57a3648c
--- /dev/null
+++ b/svgs/arrow-left-s-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/svgs/arrow-left-s-line.svg b/svgs/arrow-left-s-line.svg
new file mode 100644
index 00000000..eb6c6cc1
--- /dev/null
+++ b/svgs/arrow-left-s-line.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/wallets/client/components/card.js b/wallets/client/components/card.js
index be8678f7..efe17522 100644
--- a/wallets/client/components/card.js
+++ b/wallets/client/components/card.js
@@ -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 {children}
}
diff --git a/wallets/client/components/form/button.js b/wallets/client/components/form/button.js
new file mode 100644
index 00000000..215bb6fb
--- /dev/null
+++ b/wallets/client/components/form/button.js
@@ -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 (
+
+
+ back
+
+ )
+}
+
+export function SkipButton ({ className }) {
+ const next = useNext()
+ return skip
+}
diff --git a/wallets/client/components/form/hooks.js b/wallets/client/components/form/hooks.js
new file mode 100644
index 00000000..19023f18
--- /dev/null
+++ b/wallets/client/components/form/hooks.js
@@ -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 (
+
+
+ {children}
+
+
+ )
+}
+
+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
+}
diff --git a/wallets/client/components/form/index.js b/wallets/client/components/form/index.js
new file mode 100644
index 00000000..a5c02fbc
--- /dev/null
+++ b/wallets/client/components/form/index.js
@@ -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 (
+
+
+
+
+
+
+ {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
+ if (step === Step.RECEIVE) return
+ return
+ })}
+
+
+
+ )
+}
+
+function WalletForm () {
+ return (
+
+
+
+
+ )
+}
+
+function WalletProtocolSelector () {
+ const protocols = useWalletProtocols()
+ const [protocol, selectProtocol] = useProtocol()
+
+ return (
+
+ {
+ protocols.map(p => {
+ return (
+ selectProtocol(p)}>
+
+ {protocolDisplayName(p)}
+
+
+ )
+ })
+ }
+
+ )
+}
+
+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 (
+ <>
+
+
+ >
+ )
+}
+
+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 (
+
+ {stepIndex === 0 ?
cancel :
}
+ {!hideSkip ?
:
}
+
+ next
+
+
+
+ )
+}
+
+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 = (
+
+ {props.label}
+ {_help && (
+
+ {_help.text}
+
+ )}
+
+ {upperHint
+ ? {upperHint}
+ : (!props.required ? 'optional' : null)}
+
+
+ )
+
+ return { ...props, hint: bottomHint, label, readOnly }
+ }
+
+ switch (type) {
+ case 'text': {
+ let append
+ const lud16Domain = walletLud16Domain(wallet.name)
+ if (props.name === 'address' && lud16Domain) {
+ append = @{lud16Domain}
+ }
+ return
+ }
+ case 'password':
+ return
+ default:
+ return null
+ }
+}
diff --git a/wallets/client/components/form/settings.js b/wallets/client/components/form/settings.js
new file mode 100644
index 00000000..65ac8980
--- /dev/null
+++ b/wallets/client/components/form/settings.js
@@ -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 (
+ <>
+
+ >
+ )
+}
+
+function Separator ({ children, className }) {
+ return (
+ {children}
+ )
+}
+
+function WalletDeleteButton ({ className }) {
+ const showModal = useShowModal()
+ const wallet = useWallet()
+
+ return (
+ {
+ 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
+ })
+ }}
+ >delete
+
+ )
+}
+
+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 (
+
+
Delete wallet
+
+ Are you sure you want to delete this wallet?
+
+
+ cancel
+ delete
+
+
+ )
+}
+
+function GlobalSettings () {
+ return (
+ <>
+ global settings
+
+
+
+
+ >
+ }
+ />
+ >
+ )
+}
+
+function AutowithdrawSettings () {
+ const [{ value: threshold }] = useField('autoWithdrawThreshold')
+ const sendThreshold = Math.max(Math.floor(threshold / 10), 1)
+
+ return (
+ <>
+ sats}
+ required
+ type='number'
+ min={0}
+ groupClassName='mb-2'
+ />
+
+ max fee rate
+
+
+ configure fee budget for autowithdrawals
+ if max fee total is higher for a withdrawal, we will use it instead to find a route
+ higher fee settings increase the likelihood of successful withdrawals
+
+
+
+ }
+ name='autoWithdrawMaxFeePercent'
+ append={% }
+ required
+ type='number'
+ min={0}
+ />
+
+ max fee total
+
+
+ configure fee budget for autowithdrawals
+ if max fee rate is higher for a withdrawal, we will use it instead to find a route to your wallet
+ higher fee settings increase the likelihood of successful withdrawals
+
+
+
+ }
+ name='autoWithdrawMaxFeeTotal'
+ append={sats }
+ required
+ type='number'
+ min={0}
+ />
+ >
+ )
+}
+
+function LightningAddressSettings () {
+ return (
+ <>
+ enhance privacy of my lightning address
+
+
+ Enabling this setting hides details (ie node pubkey) of your attached wallets when anyone pays your SN lightning address or lnurl-pay
+ The lightning invoice will appear to have SN's node as the destination to preserve your wallet's privacy
+ This will incur in a 10% fee
+ Disable this setting to receive payments directly to your attached wallets (which will reveal their details to the payer)
+ Note: this privacy behavior is standard for internal zaps/payments on SN, and this setting only applies to external payments
+
+
+
+ }
+ name='proxyReceive'
+ groupClassName='mb-3'
+ />
+ >
+ )
+}
+
+function CowboyCreditsSettings () {
+ return (
+ <>
+
+ receive credits for zaps below
+
+
+ we will not attempt to forward zaps below this amount to you, you will receive credits instead
+ this setting is useful if small amounts are expensive to receive for you
+
+
+
+ }
+ name='receiveCreditsBelowSats'
+ required
+ append={sats }
+ type='number'
+ min={0}
+ />
+
+ send credits for zaps below
+
+
+ we will not attempt to send zaps below this amount from your wallet if you have enough credits
+ this setting is useful if small amounts are expensive to send for you
+
+
+
+ }
+ name='sendCreditsBelowSats'
+ required
+ append={sats }
+ type='number'
+ min={0}
+ />
+ >
+ )
+}
diff --git a/wallets/client/components/forms.js b/wallets/client/components/forms.js
deleted file mode 100644
index e9f3ad6a..00000000
--- a/wallets/client/components/forms.js
+++ /dev/null
@@ -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 (
-
-
-
- {wallet && }
-
- {wallet && (
-
-
-
- )}
-
-
- )
-}
-
-function WalletFormsProvider ({ children, wallet, refetch }) {
- const value = useMemo(() => ({ refetch, wallet }), [refetch, wallet])
- return (
-
- {children}
-
- )
-}
-
-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 (
- <>
-
- {sendRecvParam && (
-
-
-
- {protocolParam && (
-
-
-
- )}
-
-
- )}
- >
- )
-}
-
-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 (
-
-
-
- SEND
-
-
-
-
- RECEIVE
-
-
-
- )
-}
-
-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 (
-
- {sendRecvParam === 'send' ? 'sending' : 'receiving'} not supported
-
- )
- }
-
- return (
-
- {
- protocols.map(p => (
-
-
- {protocolDisplayName(p)}
-
-
- ))
- }
-
- )
-}
-
-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 (
- <>
-
-
- >
- )
-}
-
-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 (
-
- {!isTemplate(protocol) && detach }
- cancel
- {isWallet(wallet) ? 'save' : 'attach'}
-
- )
-}
-
-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 = (
-
- {props.label}
- {_help && (
-
- {_help.text}
-
- )}
-
- {upperHint
- ? {upperHint}
- : (!props.required ? 'optional' : null)}
-
-
- )
-
- return { ...props, hint: bottomHint, label, readOnly }
- }
-
- switch (type) {
- case 'text': {
- let append
- const lud16Domain = walletLud16Domain(wallet.name)
- if (props.name === 'address' && lud16Domain) {
- append = @{lud16Domain}
- }
- return
- }
- case 'password':
- return
- 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 }
-}
diff --git a/wallets/client/components/index.js b/wallets/client/components/index.js
index 6c35ddef..95e1abd5 100644
--- a/wallets/client/components/index.js
+++ b/wallets/client/components/index.js
@@ -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'
diff --git a/wallets/client/fragments/wallet.js b/wallets/client/fragments/wallet.js
index 6633ba72..6541c565 100644
--- a/wallets/client/fragments/wallet.js
+++ b/wallets/client/fragments/wallet.js
@@ -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
+ }
}
`
diff --git a/wallets/client/hooks/query.js b/wallets/client/hooks/query.js
index c196f067..b86b8856 100644
--- a/wallets/client/hooks/query.js
+++ b/wallets/client/hooks/query.js
@@ -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,
diff --git a/wallets/lib/protocols/index.js b/wallets/lib/protocols/index.js
index 8caa5f52..12fbb4ed 100644
--- a/wallets/lib/protocols/index.js
+++ b/wallets/lib/protocols/index.js
@@ -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[]} */
diff --git a/wallets/lib/protocols/lnbits.js b/wallets/lib/protocols/lnbits.js
index bf201aba..7aa8fea0 100644
--- a/wallets/lib/protocols/lnbits.js
+++ b/wallets/lib/protocols/lnbits.js
@@ -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',
diff --git a/wallets/lib/protocols/nwc.js b/wallets/lib/protocols/nwc.js
index 360516eb..522708ff 100644
--- a/wallets/lib/protocols/nwc.js
+++ b/wallets/lib/protocols/nwc.js
@@ -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',
diff --git a/wallets/lib/protocols/phoenixd.js b/wallets/lib/protocols/phoenixd.js
index 4355c910..381ba282 100644
--- a/wallets/lib/protocols/phoenixd.js
+++ b/wallets/lib/protocols/phoenixd.js
@@ -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',
diff --git a/wallets/lib/util.js b/wallets/lib/util.js
index eabb754f..ef49defe 100644
--- a/wallets/lib/util.js
+++ b/wallets/lib/util.js
@@ -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'}`
+}
diff --git a/wallets/server/resolvers/protocol.js b/wallets/server/resolvers/protocol.js
index f53e8d4f..78c1652f 100644
--- a/wallets/server/resolvers/protocol.js
+++ b/wallets/server/resolvers/protocol.js
@@ -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({
diff --git a/wallets/server/resolvers/wallet.js b/wallets/server/resolvers/wallet.js
index c1902a25..964b3d41 100644
--- a/wallets/server/resolvers/wallet.js
+++ b/wallets/server/resolvers/wallet.js
@@ -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
}