From 57036b0c4f0ce250b13dc24002c8d2cbadd2bac4 Mon Sep 17 00:00:00 2001 From: Alex Lewin <43247027+alexlwn123@users.noreply.github.com> Date: Mon, 9 Dec 2024 18:38:07 +0000 Subject: [PATCH] feat: improved error handling on ecash hooks (#104) --- .changeset/orange-rice-rest.md | 5 ++ packages/core-web/src/services/MintService.ts | 22 ++++--- packages/core-web/src/types/wallet.ts | 19 +++--- packages/core-web/src/worker/WorkerClient.ts | 9 ++- .../lib/contexts/FedimintWalletContext.ts | 39 ++++++++++-- packages/react/lib/hooks/useOpenWallet.ts | 32 +++++----- packages/react/lib/hooks/useReceiveEcash.ts | 27 ++++---- packages/react/lib/hooks/useSpendEcash.ts | 15 +++-- packages/react/src/App.css | 2 + packages/react/src/components/HooksDemo.tsx | 61 +++++++++++-------- 10 files changed, 147 insertions(+), 84 deletions(-) create mode 100644 .changeset/orange-rice-rest.md diff --git a/.changeset/orange-rice-rest.md b/.changeset/orange-rice-rest.md new file mode 100644 index 0000000..cb2416e --- /dev/null +++ b/.changeset/orange-rice-rest.md @@ -0,0 +1,5 @@ +--- +'@fedimint/react': patch +--- + +Fixed behavior of useOpenWallet hook for concurrent usages. diff --git a/packages/core-web/src/services/MintService.ts b/packages/core-web/src/services/MintService.ts index b729e7f..d1ab0d8 100644 --- a/packages/core-web/src/services/MintService.ts +++ b/packages/core-web/src/services/MintService.ts @@ -3,8 +3,10 @@ import type { Duration, JSONObject, JSONValue, + MintSpendNotesResponse, MSats, ReissueExternalNotesState, + SpendNotesState, } from '../types' export class MintService { @@ -12,10 +14,14 @@ export class MintService { /** https://web.fedimint.org/core/FedimintWallet/MintService/redeemEcash */ async redeemEcash(notes: string) { - await this.client.rpcSingle('mint', 'reissue_external_notes', { - oob_notes: notes, // "out of band notes" - extra_meta: null, - }) + return await this.client.rpcSingle( + 'mint', + 'reissue_external_notes', + { + oob_notes: notes, // "out of band notes" + extra_meta: null, + }, + ) } async reissueExternalNotes(oobNotes: string, extraMeta: JSONObject = {}) { @@ -31,7 +37,7 @@ export class MintService { subscribeReissueExternalNotes( operationId: string, - onSuccess: (state: JSONValue) => void = () => {}, + onSuccess: (state: ReissueExternalNotesState) => void = () => {}, onError: (error: string) => void = () => {}, ) { const unsubscribe = this.client.rpcStream( @@ -60,7 +66,7 @@ export class MintService { ? { nanos: 0, secs: tryCancelAfter } : tryCancelAfter - const res = await this.client.rpcSingle>( + const res = await this.client.rpcSingle( 'mint', 'spend_notes', { @@ -94,10 +100,10 @@ export class MintService { subscribeSpendNotes( operationId: string, - onSuccess: (state: JSONValue) => void = () => {}, + onSuccess: (state: SpendNotesState) => void = () => {}, onError: (error: string) => void = () => {}, ) { - return this.client.rpcStream( + return this.client.rpcStream( 'mint', 'subscribe_spend_notes', { operation_id: operationId }, diff --git a/packages/core-web/src/types/wallet.ts b/packages/core-web/src/types/wallet.ts index f4c53a9..396e31d 100644 --- a/packages/core-web/src/types/wallet.ts +++ b/packages/core-web/src/types/wallet.ts @@ -1,4 +1,3 @@ -import { type MintService } from '../services' import { MSats, Duration, JSONValue } from './utils' const MODULE_KINDS = ['', 'ln', 'mint'] as const @@ -81,13 +80,18 @@ type StreamResult = type CancelFunction = () => void -type ReissueExternalNotesState = - | 'Created' - | 'Issuing' - | 'Done' - | { Failed: { error: string } } +type ReissueExternalNotesState = 'Created' | 'Issuing' | 'Done' +// | { Failed: { error: string } } + +type MintSpendNotesResponse = Array -type MintSpendNotesResponse = ReturnType +type SpendNotesState = + | 'Created' + | 'UserCanceledProcessing' + | 'UserCanceledSuccess' + | 'UserCanceledFailure' + | 'Success' + | 'Refunded' export { LightningGateway, @@ -106,4 +110,5 @@ export { CancelFunction, ReissueExternalNotesState, MintSpendNotesResponse, + SpendNotesState, } diff --git a/packages/core-web/src/worker/WorkerClient.ts b/packages/core-web/src/worker/WorkerClient.ts index 3c575bf..bd169d3 100644 --- a/packages/core-web/src/worker/WorkerClient.ts +++ b/packages/core-web/src/worker/WorkerClient.ts @@ -209,11 +209,10 @@ export class WorkerClient { }) } - rpcSingle( - module: ModuleKind, - method: string, - body: JSONValue, - ) { + rpcSingle< + Response extends JSONValue = JSONValue, + Error extends string = string, + >(module: ModuleKind, method: string, body: JSONValue) { logger.debug('WorkerClient - rpcSingle', module, method, body) return new Promise((resolve, reject) => { this.rpcStream(module, method, body, resolve, reject) diff --git a/packages/react/lib/contexts/FedimintWalletContext.ts b/packages/react/lib/contexts/FedimintWalletContext.ts index 64ba336..e5ccf02 100644 --- a/packages/react/lib/contexts/FedimintWalletContext.ts +++ b/packages/react/lib/contexts/FedimintWalletContext.ts @@ -1,5 +1,11 @@ import { FedimintWallet } from '@fedimint/core-web' -import { createContext, createElement } from 'react' +import { + createContext, + createElement, + useEffect, + useMemo, + useState, +} from 'react' let wallet: FedimintWallet @@ -8,6 +14,8 @@ type FedimintWalletConfig = { debug?: boolean } +export type WalletStatus = 'open' | 'closed' | 'opening' + export const setupFedimintWallet = (config: FedimintWalletConfig) => { wallet = new FedimintWallet(!!config.lazy) if (config.debug) { @@ -16,7 +24,12 @@ export const setupFedimintWallet = (config: FedimintWalletConfig) => { } export const FedimintWalletContext = createContext< - { wallet: FedimintWallet } | undefined + | { + wallet: FedimintWallet + walletStatus: WalletStatus + setWalletStatus: (status: WalletStatus) => void + } + | undefined >(undefined) export type FedimintWalletProviderProps = {} @@ -24,6 +37,7 @@ export type FedimintWalletProviderProps = {} export const FedimintWalletProvider = ( parameters: React.PropsWithChildren, ) => { + const [walletStatus, setWalletStatus] = useState('closed') const { children } = parameters if (!wallet) @@ -31,7 +45,24 @@ export const FedimintWalletProvider = ( 'You must call setupFedimintWallet() first. See the getting started guide.', ) - const props = { value: { wallet } } + const contextValue = useMemo( + () => ({ + wallet, + walletStatus, + setWalletStatus, + }), + [walletStatus], + ) + + useEffect(() => { + wallet.waitForOpen().then(() => { + setWalletStatus('open') + }) + }, [wallet]) - return createElement(FedimintWalletContext.Provider, props, children) + return createElement( + FedimintWalletContext.Provider, + { value: contextValue }, + children, + ) } diff --git a/packages/react/lib/hooks/useOpenWallet.ts b/packages/react/lib/hooks/useOpenWallet.ts index b5f2dcf..92784cc 100644 --- a/packages/react/lib/hooks/useOpenWallet.ts +++ b/packages/react/lib/hooks/useOpenWallet.ts @@ -1,11 +1,16 @@ -import { useCallback, useEffect, useState } from 'react' -import { useFedimintWallet } from '.' - -type WalletStatus = 'open' | 'closed' | 'opening' +import { useCallback, useContext } from 'react' +import { FedimintWalletContext } from '../contexts/FedimintWalletContext' export const useOpenWallet = () => { - const wallet = useFedimintWallet() - const [walletStatus, setWalletStatus] = useState() + const value = useContext(FedimintWalletContext) + + if (!value) { + throw new Error( + 'useOpenWallet must be used within a FedimintWalletProvider', + ) + } + + const { wallet, walletStatus, setWalletStatus } = value const openWallet = useCallback(() => { if (walletStatus === 'open') return @@ -22,21 +27,12 @@ export const useOpenWallet = () => { setWalletStatus('opening') - const res = await wallet.joinFederation(invite) - setWalletStatus(res ? 'open' : 'closed') + await wallet.joinFederation(invite).then((res) => { + setWalletStatus(res ? 'open' : 'closed') + }) }, [wallet], ) - useEffect(() => { - wallet.waitForOpen().then(() => { - setWalletStatus('open') - }) - - return () => { - setWalletStatus('closed') - } - }, [wallet]) - return { walletStatus, openWallet, joinFederation } } diff --git a/packages/react/lib/hooks/useReceiveEcash.ts b/packages/react/lib/hooks/useReceiveEcash.ts index 047438b..69c5257 100644 --- a/packages/react/lib/hooks/useReceiveEcash.ts +++ b/packages/react/lib/hooks/useReceiveEcash.ts @@ -1,22 +1,25 @@ import { useCallback, useEffect, useState } from 'react' import { useFedimintWallet, useOpenWallet } from '.' +import { ReissueExternalNotesState } from '@fedimint/core-web' export const useReceiveEcash = () => { const wallet = useFedimintWallet() const { walletStatus } = useOpenWallet() const [operationId, setOperationId] = useState() - const [notes, setNotes] = useState() - const [state, setState] = useState() + const [state, setState] = useState() + const [error, setError] = useState() useEffect(() => { if (!operationId) return const unsubscribe = wallet.mint.subscribeReissueExternalNotes( operationId, - (_state) => (_state ? setState(_state) : setState(undefined)), + (_state) => { + setState(_state) + }, (error) => { - console.error('ECASH SPEND STATE ERROR', error) + setError(error) }, ) @@ -28,18 +31,20 @@ export const useReceiveEcash = () => { const redeemEcash = useCallback( async (notes: string) => { if (walletStatus !== 'open') throw new Error('Wallet is not open') - const response = await wallet.mint.redeemEcash(notes) - console.error('REEDEEEM', response) - // setOperationId(response.operation_id) - // setNotes(response.notes) - return response + try { + const response = await wallet.mint.redeemEcash(notes) + setOperationId(response) + } catch (e) { + setState('Error') + setError(e as string) + } }, - [wallet], + [wallet, walletStatus], ) return { redeemEcash, - notes, state, + error, } } diff --git a/packages/react/lib/hooks/useSpendEcash.ts b/packages/react/lib/hooks/useSpendEcash.ts index bf69668..a451e21 100644 --- a/packages/react/lib/hooks/useSpendEcash.ts +++ b/packages/react/lib/hooks/useSpendEcash.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react' import { useFedimintWallet, useOpenWallet } from '.' +import { type SpendNotesState } from '@fedimint/core-web' export const useSpendEcash = () => { const wallet = useFedimintWallet() @@ -7,16 +8,21 @@ export const useSpendEcash = () => { const [operationId, setOperationId] = useState() const [notes, setNotes] = useState() - const [state, setState] = useState() + + const [state, setState] = useState() + const [error, setError] = useState() useEffect(() => { if (!operationId) return const unsubscribe = wallet.mint.subscribeSpendNotes( operationId, - (_state) => (_state ? setState(_state) : setState(undefined)), + (_state) => { + setState(_state) + }, (error) => { - console.error('ECASH SPEND STATE ERROR', error) + setState('Error') + setError(error) }, ) @@ -36,12 +42,13 @@ export const useSpendEcash = () => { setNotes(response.notes) return response.notes }, - [wallet], + [wallet, walletStatus], ) return { spendEcash, notes, state, + error, } } diff --git a/packages/react/src/App.css b/packages/react/src/App.css index 05c7836..b954ded 100644 --- a/packages/react/src/App.css +++ b/packages/react/src/App.css @@ -50,4 +50,6 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + user-select: all; + max-width: 200px; } diff --git a/packages/react/src/components/HooksDemo.tsx b/packages/react/src/components/HooksDemo.tsx index 9834637..ba887da 100644 --- a/packages/react/src/components/HooksDemo.tsx +++ b/packages/react/src/components/HooksDemo.tsx @@ -12,9 +12,9 @@ const TEST_FEDERATION_INVITE = 'fed11qgqzc2nhwden5te0vejkg6tdd9h8gepwvejkg6tdd9h8garhduhx6at5d9h8jmn9wshxxmmd9uqqzgxg6s3evnr6m9zdxr6hxkdkukexpcs3mn7mj3g5pc5dfh63l4tj6g9zk4er' function HooksDemo() { - const [bolt11Input, setBolt11Input] = useState() - const [ecashAmount, setEcashAmount] = useState() - const [ecashInput, setEcashInput] = useState() + const [bolt11Input, setBolt11Input] = useState('') + const [ecashAmount, setEcashAmount] = useState('') + const [ecashInput, setEcashInput] = useState('') // Balance const balance = useBalance() @@ -32,10 +32,19 @@ function HooksDemo() { useSendLightning() // SendEcash - const { spendEcash, notes, state: ecashState } = useSpendEcash() + const { + spendEcash, + notes, + state: spendEcashState, + error: spendEcashError, + } = useSpendEcash() // ReceiveEcash - const { redeemEcash } = useReceiveEcash() + const { + redeemEcash, + state: receiveEcashState, + error: receiveEcashError, + } = useReceiveEcash() return ( <> @@ -147,14 +156,14 @@ function HooksDemo() { setEcashAmount(parseInt(e.target.value))} + onChange={(e) => setEcashAmount(e.target.value)} />
spendEcash(amount) @@ -162,21 +171,23 @@ function HooksDemo() {
ecashStatus

- {typeof ecashState === 'string' - ? ecashState - : typeof ecashState === 'object' - ? Object.keys(ecashState)[0] + {typeof spendEcashState === 'string' + ? spendEcashState + : typeof spendEcashState === 'object' + ? Object.keys(spendEcashState)[0] : 'no ecash status'}

ecashNote -

{notes}

+

+ {notes} +

- {/*
+
error -

{paymentError}

-
*/} +

{spendEcashError}

+
useReceiveEcash() @@ -198,23 +209,19 @@ function HooksDemo() {
- ecashStatus + ecashRedeemStatus

- {typeof ecashState === 'string' - ? ecashState - : typeof ecashState === 'object' - ? Object.keys(ecashState)[0] + {typeof receiveEcashState === 'string' + ? receiveEcashState + : typeof receiveEcashState === 'object' + ? Object.keys(receiveEcashState)[0] : 'no ecash status'}

- ecashNote -

{notes}

-
- {/*
error -

{paymentError}

-
*/} +

{receiveEcashError}

+