Skip to content

Commit 525b6ba

Browse files
Merge pull request #33 from reserve-protocol/fix/tx-failed
Fix: restore after tx reverted/rejected
2 parents 0212375 + dd2b61b commit 525b6ba

9 files changed

Lines changed: 1396 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## [2.4.1] - 2026-07-08
2+
3+
### Fixed
4+
5+
- The submit CTA no longer stays disabled/loading indefinitely after a transaction reverts or is rejected in the wallet. The quote is now refreshed immediately on failure (instead of resuming from the stale, often expired, frozen quote), and the gas-estimate simulation is re-run whenever a new quote lands — previously an errored simulation query could never recover when the refreshed quote carried identical tx bytes, leaving the button stuck on "Simulation failed - Refetching quote".
6+
17
## [2.4.0] - 2026-07-08
28

39
### Added

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@reserve-protocol/react-zapper",
3-
"version": "2.4.0",
3+
"version": "2.4.1",
44
"type": "module",
55
"packageManager": "pnpm@11.1.1",
66
"description": "React component for DTF minting with zap functionality",
@@ -25,6 +25,7 @@
2525
"build:demo": "vite build --mode demo",
2626
"prepublishOnly": "pnpm run build",
2727
"lint": "eslint src --ext .ts,.tsx",
28+
"test": "vitest run",
2829
"clean": "rm -rf dist",
2930
"extract": "lingui extract",
3031
"compile": "lingui compile"
@@ -82,6 +83,7 @@
8283
"@radix-ui/react-select": "^2.2.5",
8384
"@radix-ui/react-slot": "^1.2.3",
8485
"@rainbow-me/rainbowkit": "^2.2.5",
86+
"@testing-library/react": "^16.3.2",
8587
"@types/mixpanel-browser": "^2.50.2",
8688
"@types/react": "^18.3.1",
8789
"@types/react-dom": "^18.3.1",
@@ -94,6 +96,7 @@
9496
"eslint": "^8.0.0",
9597
"eslint-plugin-react": "^7.33.0",
9698
"eslint-plugin-react-hooks": "^4.6.0",
99+
"jsdom": "^29.1.1",
97100
"postcss": "^8.4.49",
98101
"react": "^18.0.0",
99102
"react-dom": "^18.0.0",
@@ -102,6 +105,7 @@
102105
"viem": "2.50.4",
103106
"vite": "^7.0.2",
104107
"vite-plugin-dts": "^4.5.4",
108+
"vitest": "3.2.4",
105109
"wagmi": "2.19.5"
106110
}
107111
}

pnpm-lock.yaml

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

src/components/ui/swap.tsx

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -268,26 +268,26 @@ const SLOW_LOADING_TEXTS: MessageDescriptor[] = [
268268

269269
const SlowLoading = ({ enabled }: { enabled: boolean }) => {
270270
const { t } = useLingui()
271-
const [countdown, setCountdown] = useState(60)
271+
const [elapsed, setElapsed] = useState(1)
272272
const [textIndex, setTextIndex] = useState(0)
273273

274274
useEffect(() => {
275275
if (!enabled) {
276-
setCountdown(60)
276+
setElapsed(1)
277277
setTextIndex(0)
278278
return
279279
}
280280

281-
const countdownInterval = setInterval(() => {
282-
setCountdown((prev) => (prev > 0 ? prev - 1 : 0))
281+
const elapsedInterval = setInterval(() => {
282+
setElapsed((prev) => prev + 1)
283283
}, 1000)
284284

285285
const textInterval = setInterval(() => {
286286
setTextIndex((prev) => (prev + 1) % SLOW_LOADING_TEXTS.length)
287287
}, 5000)
288288

289289
return () => {
290-
clearInterval(countdownInterval)
290+
clearInterval(elapsedInterval)
291291
clearInterval(textInterval)
292292
}
293293
}, [enabled])
@@ -305,14 +305,7 @@ const SlowLoading = ({ enabled }: { enabled: boolean }) => {
305305
<Loader size={16} className="animate-spin-slow" />
306306
{t(SLOW_LOADING_TEXTS[textIndex])}
307307
</div>
308-
<div
309-
className={cn(
310-
'text-muted-foreground',
311-
countdown > 0 ? 'min-w-4' : ''
312-
)}
313-
>
314-
{countdown > 0 && `${countdown}s`}
315-
</div>
308+
<div className="text-muted-foreground min-w-4">{`${elapsed}s`}</div>
316309
</div>
317310
</div>
318311
)

src/components/zap-mint/submit-zap.tsx

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -209,22 +209,25 @@ const SubmitZapButton = ({
209209
)
210210
}, [gas, chainId])
211211

212-
const { error: simulationError, failureReason: simulationFailureReason } =
213-
useEstimateGas({
214-
to: tx?.to as Address,
215-
data: tx?.data as Hex,
216-
value: BigInt(tx?.value || 0),
217-
gas: gasLimit,
218-
chainId,
219-
query: {
220-
// Stop simulating once a tx is in flight/done — a failed simulation
221-
// would force a quote refetch and overwrite the frozen result.
222-
enabled: readyToSubmit && !!tx && !ongoingTx,
223-
refetchIntervalInBackground: true,
224-
refetchOnWindowFocus: true,
225-
refetchInterval: 2_000,
226-
},
227-
})
212+
const {
213+
error: simulationError,
214+
failureReason: simulationFailureReason,
215+
refetch: refetchSimulation,
216+
} = useEstimateGas({
217+
to: tx?.to as Address,
218+
data: tx?.data as Hex,
219+
value: BigInt(tx?.value || 0),
220+
gas: gasLimit,
221+
chainId,
222+
query: {
223+
// Stop simulating once a tx is in flight/done — a failed simulation
224+
// would force a quote refetch and overwrite the frozen result.
225+
enabled: readyToSubmit && !!tx && !ongoingTx,
226+
refetchIntervalInBackground: true,
227+
refetchOnWindowFocus: true,
228+
refetchInterval: 2_000,
229+
},
230+
})
228231

229232
const simulationFailed = useMemo(
230233
() => Boolean(simulationError || simulationFailureReason),
@@ -234,12 +237,28 @@ const SubmitZapButton = ({
234237
const queryClient = useQueryClient()
235238
useEffect(() => {
236239
if (simulationFailed) {
237-
queryClient.setQueriesData({ queryKey: ['zapDeploy'] }, () => undefined)
238240
queryClient.invalidateQueries({ queryKey: ['zapDeploy'] })
239241
refetchQuote.fn?.()
240242
}
241243
}, [simulationFailed, refetchQuote, queryClient])
242244

245+
// Once its retries are exhausted, an errored estimateGas query never runs
246+
// again on its own, and a refreshed quote can carry identical tx bytes
247+
// (same queryKey). Re-arm the simulation whenever a quote lands while the
248+
// last simulation failed, so a failed attempt can't leave the CTA disabled
249+
// forever.
250+
useEffect(() => {
251+
if (ongoingTx || !readyToSubmit || !tx || !simulationFailed) return
252+
refetchSimulation()
253+
}, [
254+
tx,
255+
validUntil,
256+
ongoingTx,
257+
readyToSubmit,
258+
simulationFailed,
259+
refetchSimulation,
260+
])
261+
243262
const {
244263
data: receipt,
245264
isMining: validatingTx,
@@ -356,6 +375,12 @@ const SubmitZapButton = ({
356375
) {
357376
setOngoingTx(false)
358377
}
378+
// The quote was frozen while the tx was in flight, so after a failure it
379+
// is stale (often already expired) — replace it right away instead of
380+
// waiting for the next refresh tick.
381+
if (approvalTxError || txError || isErrorApproval || isErrorSend) {
382+
refetchQuote.fn?.()
383+
}
359384
}, [
360385
receipt,
361386
approvalReceipt,
@@ -364,6 +389,7 @@ const SubmitZapButton = ({
364389
isErrorApproval,
365390
isErrorSend,
366391
setOngoingTx,
392+
refetchQuote,
367393
])
368394

369395
useTrackIndexDTFZapError({

src/components/zap-mint/zap-error-msg.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ const ReportButton = ({ error }: { error?: string }) => {
106106
}
107107
}
108108

109+
const labels = [t`Sending...`, t`Reported`, t`Report`]
110+
const label = isLoading ? labels[0] : hasReported ? labels[1] : labels[2]
111+
109112
return (
110113
<div className="flex flex-col items-end gap-0.5">
111114
<Button
@@ -115,7 +118,16 @@ const ReportButton = ({ error }: { error?: string }) => {
115118
isLoading || hasReported || !sessionId || !quoteId || !retryId
116119
}
117120
>
118-
{isLoading ? t`Sending...` : hasReported ? t`Reported` : t`Report`}
121+
{/* Overlay every state's label so the button keeps the width of the
122+
widest one and doesn't resize/overflow when the state changes */}
123+
<span className="grid text-center">
124+
{labels.map((l) => (
125+
<span key={l} className="col-start-1 row-start-1 invisible">
126+
{l}
127+
</span>
128+
))}
129+
<span className="col-start-1 row-start-1">{label}</span>
130+
</span>
119131
</Button>
120132
{reportFailed && (
121133
<span className="text-red-500 text-[10px]">

tests/setup.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { vi } from 'vitest'
2+
3+
// mixpanel does real network I/O at init/track time — neutralize it entirely.
4+
vi.mock('mixpanel-browser/src/loaders/loader-module-core', () => ({
5+
default: {
6+
init: () => {},
7+
track: () => {},
8+
time_event: () => {},
9+
register: () => {},
10+
unregister: () => {},
11+
people: { set: () => {} },
12+
},
13+
}))
14+
15+
// jsdom is missing a few browser APIs used by radix/lucide/etc.
16+
class ResizeObserverStub {
17+
observe() {}
18+
unobserve() {}
19+
disconnect() {}
20+
}
21+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
22+
const g = globalThis as any
23+
g.ResizeObserver = g.ResizeObserver ?? ResizeObserverStub
24+
g.matchMedia =
25+
g.matchMedia ??
26+
((query: string) => ({
27+
matches: false,
28+
media: query,
29+
onchange: null,
30+
addListener: () => {},
31+
removeListener: () => {},
32+
addEventListener: () => {},
33+
removeEventListener: () => {},
34+
dispatchEvent: () => false,
35+
}))
36+
if (typeof Element !== 'undefined') {
37+
Element.prototype.scrollIntoView = Element.prototype.scrollIntoView || (() => {})
38+
Element.prototype.hasPointerCapture =
39+
Element.prototype.hasPointerCapture || (() => false)
40+
Element.prototype.releasePointerCapture =
41+
Element.prototype.releasePointerCapture || (() => {})
42+
}

0 commit comments

Comments
 (0)