Skip to content

Commit 461f07a

Browse files
authored
Merge pull request #259 from BootNodeDev/feat/tx-toasts
feat: Toast for tx lifecycle
2 parents 3e4233c + b4d1b07 commit 461f07a

File tree

6 files changed

+2117
-2450
lines changed

6 files changed

+2117
-2450
lines changed

pnpm-lock.yaml

Lines changed: 1889 additions & 2437 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/sharedComponents/TransactionButton.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@ import { type Hash, type TransactionReceipt } from 'viem'
44
import { useWaitForTransactionReceipt } from 'wagmi'
55

66
import { withWalletStatusVerifier } from '@/src/components/sharedComponents/WalletStatusVerifier'
7+
import { useTransactionNotification } from '@/src/lib/toast/TransactionNotificationProvider'
78

89
interface TransactionButtonProps extends ComponentProps<'button'> {
910
confirmations?: number
1011
labelSending?: string
1112
onMined?: (receipt: TransactionReceipt) => void
12-
transaction: () => Promise<Hash>
13+
transaction: {
14+
(): Promise<Hash>
15+
methodId?: string
16+
}
1317
}
1418

1519
/**
@@ -40,6 +44,7 @@ const TransactionButton = withWalletStatusVerifier<TransactionButtonProps>(
4044
const [hash, setHash] = useState<Hash>()
4145
const [isPending, setIsPending] = useState<boolean>(false)
4246

47+
const { watchTx } = useTransactionNotification()
4348
const { data: receipt } = useWaitForTransactionReceipt({
4449
hash: hash,
4550
confirmations,
@@ -60,7 +65,9 @@ const TransactionButton = withWalletStatusVerifier<TransactionButtonProps>(
6065
const handleSendTransaction = async () => {
6166
setIsPending(true)
6267
try {
63-
const hash = await transaction()
68+
const txPromise = transaction()
69+
watchTx({ txPromise, methodId: transaction.methodId })
70+
const hash = await txPromise
6471
setHash(hash)
6572
} catch (error: unknown) {
6673
console.error('Error sending transaction', error instanceof Error ? error.message : error)

src/lib/toast/ToastNotification.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Hash } from 'viem'
2+
3+
import { ExplorerLink } from '@/src/components/sharedComponents/ExplorerLink'
4+
import { useWeb3Status } from '@/src/hooks/useWeb3Status'
5+
6+
export const ToastNotification = ({
7+
hash,
8+
message,
9+
}: {
10+
message: JSX.Element | string
11+
hash?: Hash
12+
showClose?: boolean
13+
}) => {
14+
const { readOnlyClient } = useWeb3Status()
15+
const chain = readOnlyClient?.chain
16+
17+
if (!chain) return null
18+
19+
return (
20+
<div>
21+
<div>{message}</div>
22+
{hash && <ExplorerLink chain={chain} hashOrAddress={hash} />}
23+
</div>
24+
)
25+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { createContext, type FC, type PropsWithChildren, useContext } from 'react'
2+
3+
import toast from 'react-hot-toast'
4+
import {
5+
Hash,
6+
type ReplacementReturnType,
7+
type SignMessageErrorType,
8+
type TransactionExecutionError,
9+
} from 'viem'
10+
11+
import { ExplorerLink } from '@/src/components/sharedComponents/ExplorerLink'
12+
import { useWeb3Status } from '@/src/hooks/useWeb3Status'
13+
import { ToastNotification } from '@/src/lib/toast/ToastNotification'
14+
15+
type WatchSignatureArgs = {
16+
successMessage?: string
17+
message: JSX.Element | string
18+
signaturePromise: Promise<Hash>
19+
onToastId?: (toastId: string) => void
20+
showSuccessToast?: boolean
21+
}
22+
23+
type WatchHashArgs = {
24+
message?: string
25+
successMessage?: string
26+
errorMessage?: string
27+
hash: Hash
28+
toastId?: string
29+
}
30+
31+
type WatchTxArgs = { txPromise: Promise<Hash>; methodId?: string }
32+
33+
type TransactionContextValue = {
34+
watchSignature: (args: WatchSignatureArgs) => void
35+
watchHash: (args: WatchHashArgs) => void
36+
watchTx: (args: WatchTxArgs) => void
37+
}
38+
39+
const TransactionContext = createContext<TransactionContextValue | undefined>(undefined)
40+
41+
export const TransactionNotificationProvider: FC<PropsWithChildren> = ({ children }) => {
42+
const { readOnlyClient } = useWeb3Status()
43+
const chain = readOnlyClient?.chain
44+
45+
async function watchSignature({
46+
message,
47+
onToastId,
48+
showSuccessToast = true,
49+
signaturePromise,
50+
successMessage = 'Signature received!',
51+
}: WatchSignatureArgs) {
52+
const toastId = toast.loading(() => <ToastNotification message={message} />)
53+
onToastId?.(toastId)
54+
55+
try {
56+
await signaturePromise
57+
if (showSuccessToast) {
58+
toast.success(<ToastNotification message={successMessage} />, {
59+
id: toastId,
60+
})
61+
}
62+
} catch (e) {
63+
const error = e as TransactionExecutionError | SignMessageErrorType
64+
let message = error.message || 'An error occurred'
65+
if ('shortMessage' in error) {
66+
message = error.shortMessage
67+
}
68+
toast.error(<ToastNotification message={message} />, { id: toastId })
69+
}
70+
}
71+
72+
async function watchHash({
73+
errorMessage = 'Transaction was reverted!',
74+
hash,
75+
message = 'Transaction sent',
76+
successMessage = 'Transaction has been mined!',
77+
toastId,
78+
}: WatchHashArgs) {
79+
if (!chain) {
80+
console.error('Chain is not defined')
81+
return
82+
}
83+
84+
if (!readOnlyClient) {
85+
console.error('ReadOnlyClient is not defined')
86+
return
87+
}
88+
89+
toast.loading(() => <ToastNotification message={message} />, {
90+
id: toastId,
91+
})
92+
93+
try {
94+
let replacedTx = null as ReplacementReturnType | null
95+
const receipt = await readOnlyClient.waitForTransactionReceipt({
96+
hash,
97+
onReplaced: (replacedTxData) => (replacedTx = replacedTxData),
98+
})
99+
100+
if (replacedTx !== null) {
101+
if (['replaced', 'cancelled'].includes(replacedTx.reason)) {
102+
toast.error(
103+
<div>
104+
<div>Transaction has been {replacedTx.reason}!</div>
105+
<ExplorerLink chain={chain} hashOrAddress={replacedTx.transaction.hash} />
106+
</div>,
107+
{ id: toastId },
108+
)
109+
} else {
110+
toast.success(
111+
<div>
112+
<div>{successMessage}</div>
113+
<ExplorerLink chain={chain} hashOrAddress={replacedTx.transaction.hash} />
114+
</div>,
115+
{ id: toastId },
116+
)
117+
}
118+
return
119+
}
120+
121+
if (receipt.status === 'success') {
122+
toast.success(
123+
<div>
124+
<div>{successMessage}</div>
125+
<ExplorerLink chain={chain} hashOrAddress={hash} />
126+
</div>,
127+
{ id: toastId },
128+
)
129+
} else {
130+
toast.error(
131+
<div>
132+
<div>{errorMessage}</div>
133+
<ExplorerLink chain={chain} hashOrAddress={hash} />
134+
</div>,
135+
{ id: toastId },
136+
)
137+
}
138+
} catch (error) {
139+
console.error('Error watching hash', error)
140+
}
141+
}
142+
143+
async function watchTx({ methodId, txPromise }: WatchTxArgs) {
144+
const transactionMessage = methodId ? `Transaction for calling ${methodId}` : 'Transaction'
145+
146+
let toastId: string = ''
147+
await watchSignature({
148+
message: `Signature requested: ${transactionMessage}`,
149+
signaturePromise: txPromise,
150+
showSuccessToast: false,
151+
onToastId: (id) => (toastId = id),
152+
})
153+
154+
const hash = await txPromise
155+
await watchHash({
156+
hash,
157+
toastId,
158+
message: `${transactionMessage} is pending to be mined ...`,
159+
successMessage: `${transactionMessage} has been mined!`,
160+
errorMessage: `${transactionMessage} has reverted!`,
161+
})
162+
}
163+
164+
return (
165+
<TransactionContext.Provider value={{ watchTx, watchHash, watchSignature }}>
166+
{children}
167+
</TransactionContext.Provider>
168+
)
169+
}
170+
171+
// eslint-disable-next-line react-refresh/only-export-components
172+
export function useTransactionNotification() {
173+
const context = useContext(TransactionContext)
174+
if (context === undefined) {
175+
throw new Error(
176+
'useTransactionNotification must be used within a TransactionNotificationProvider',
177+
)
178+
}
179+
return context
180+
}

src/routes/__root.tsx

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Footer } from '@/src/components/sharedComponents/Footer'
99
import { Header } from '@/src/components/sharedComponents/Header'
1010
import { TanStackReactQueryDevtools } from '@/src/components/sharedComponents/TanStackReactQueryDevtools'
1111
import { TanStackRouterDevtools } from '@/src/components/sharedComponents/TanStackRouterDevtools'
12+
import { TransactionNotificationProvider } from '@/src/lib/toast/TransactionNotificationProvider'
1213
import { Web3Provider } from '@/src/providers/Web3Provider'
1314
import Styles from '@/src/styles'
1415

@@ -24,19 +25,21 @@ function Root() {
2425
<Styles />
2526
<Web3Provider>
2627
<ModalProvider>
27-
<Wrapper>
28-
<Header />
29-
<Main>
30-
<Outlet />
31-
</Main>
32-
<Footer />
33-
<TanStackReactQueryDevtools />
34-
<TanStackRouterDevtools />
35-
</Wrapper>
28+
<TransactionNotificationProvider>
29+
<Wrapper>
30+
<Header />
31+
<Main>
32+
<Outlet />
33+
</Main>
34+
<Footer />
35+
<TanStackReactQueryDevtools />
36+
<TanStackRouterDevtools />
37+
</Wrapper>
38+
<Toaster />
39+
</TransactionNotificationProvider>
3640
<ModalContainer />
3741
</ModalProvider>
3842
</Web3Provider>
39-
<Toaster />
4043
<Analytics />
4144
</ThemeProvider>
4245
)

src/utils/getExplorerLink.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const getExplorerLink = ({ chain, explorerUrl, hashOrAddress }: GetExplor
2828
if (isHash(hashOrAddress)) {
2929
return explorerUrl
3030
? `${explorerUrl}/tx/${hashOrAddress}`
31-
: `${chain.blockExplorers?.default}/tx/${hashOrAddress}`
31+
: `${chain.blockExplorers?.default.url}/tx/${hashOrAddress}`
3232
}
3333

3434
throw new Error('Invalid hash or address')

0 commit comments

Comments
 (0)