From 5e25a0659c0bce70f989d2eae7160d3136935b03 Mon Sep 17 00:00:00 2001 From: Fabien Date: Wed, 18 Feb 2026 15:07:18 +0100 Subject: [PATCH 1/3] Add a fallback polling when the ws connection is lost on mobile It is common on mobile to move the widget to the background, which causes the DNS to fail. Other issues might also happen, including the even already being fired when the widget is back to foreground. To cover all these use cases this commit adds a polling phase when the widget become visible (event triggered when the browser page goes to foreground on mobile). It will poll for the last 5 transactions from the history once and check if there is a matching payment tx, otherwise it retries every second for 5s which should be plenty for the tx to be discovered. The retry stops as soon as the payment is marked successful. In practice the mobile apps (either Marlin or Cashtab) add a modal window and a small delay before returning to the browser so the retry is not needed most of the time. This has been tested with a remote console, logs and tweaks to make sure all the scenarios are covered properly. Note that this commit does NOT fix the existing bugs. This should be done in other commits/PRs and reference the appropriated issue. More specifically the following bugs are worked around but not fixed: - The returned object for `getAddressDetails` does not match its interface. This needs to be dealed with on server side. - There is no handling of the chronik ws connection closing when the browser goes to background (which cuts network on mobile). No attempt is made to reconnect the lost ws connection either, so polling takes over. - The `shouldTriggerOnSuccess` function doesn't filter out the payment ID when it's undefined, meaning it will match any tx that looks like the payment one but has no payment ID associated with it. This commit avoids this by exiting early and avoiding the call in this case. - Confirmed transactions are ignored. I have no idea why this is the case but it is consistent across the codebase, so any transaction being mined before it entered the chronik instance mempool will be missed. --- .../lib/components/Widget/WidgetContainer.tsx | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/react/lib/components/Widget/WidgetContainer.tsx b/react/lib/components/Widget/WidgetContainer.tsx index dba0fb8d..d8724b5d 100644 --- a/react/lib/components/Widget/WidgetContainer.tsx +++ b/react/lib/components/Widget/WidgetContainer.tsx @@ -19,7 +19,9 @@ import { shouldTriggerOnSuccess, isPropsTrue, DEFAULT_DONATION_RATE, + parseOpReturnData, } from '../../util'; +import { getAddressDetails } from '../../util/api-client'; import Widget, { WidgetProps } from './Widget'; @@ -157,6 +159,7 @@ export const WidgetContainer: React.FunctionComponent = const [thisPrice, setThisPrice] = useState(0); const [usdPrice, setUsdPrice] = useState(0); const [success, setSuccess] = useState(false); + const [retryCount, setRetryCount] = useState(0); const { enqueueSnackbar } = useSnackbar(); const [shiftCompleted, setShiftCompleted] = useState(false); @@ -280,12 +283,130 @@ export const WidgetContainer: React.FunctionComponent = [handlePayment], ); + const checkForTransactions = useCallback(async (): Promise => { + if (success) { + // Payment already succeeded, stop checking + return true; + } + + try { + const history = await getAddressDetails(to, apiBaseUrl); + // Save time by only checking the last few transactions + const recentTxs = history.slice(0, 5); + + for (const apiTx of recentTxs) { + // FIXME: the getAddressDetails API returns an object that is NOT + // the same as the Transaction[] type, so we need to convert it + const parsedOpReturn = apiTx.opReturn + ? parseOpReturnData(apiTx.opReturn) + : {}; + + const tx: Transaction = { + hash: apiTx.hash, + amount: apiTx.amount, + paymentId: parsedOpReturn.paymentId ?? '', + confirmed: apiTx.confirmed, + message: parsedOpReturn.message ?? '', + timestamp: apiTx.timestamp, + address: (apiTx.address as unknown as { address: string }).address, + rawMessage: parsedOpReturn.rawMessage, + opReturn: apiTx.opReturn, + }; + handleNewTransaction(tx); + } + return true; + } catch (error) { + // Failed to fetch history, there is no point in retrying + return false; + } + }, [success, to, apiBaseUrl, handleNewTransaction]); + useEffect(() => { thisNewTxs?.map(tx => { handleNewTransaction(tx); }); }, [thisNewTxs, handleNewTransaction]); + useEffect(() => { + if (typeof document === 'undefined') { + return; + } + + let wasHidden = document.hidden; + let hiddenTimestamp = 0; + + const handleVisibilityChange = async () => { + if (document.hidden) { + wasHidden = true; + hiddenTimestamp = Date.now(); + return; + } + + // Debounce the event to avoid querying the history for spurious events. + // This happens specifically when the user clicks on the paybutton, + // before the app handles the deeplink. + if (!wasHidden || Date.now() - hiddenTimestamp < 200) { + wasHidden = false; + return; + } + + wasHidden = false; + + if (!to || success) { + // No destination or payment already succeeded, skip checking + return; + } + + if (!disablePaymentId && !thisPaymentId) { + // Skip if paymentId is required but not yet set. This avoids matching + // transactions against undefined payment IDs. + return; + } + + // Run immediately (attempt 1) + const checkCompleted = await checkForTransactions(); + + // If check completed successfully but payment hasn't succeeded yet, + // trigger retries. We might be missing the payment transaction. + if (checkCompleted && !success) { + // Start retries + setRetryCount(1); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [to, thisPaymentId, success, disablePaymentId]); + + // Retry mechanism: check every second if payment hasn't succeeded yet + useEffect(() => { + if (retryCount === 0 || success || retryCount >= 5) { + // Retry up to 5 times or until the payment succeeds. If the payment tx + // is not found within this time period, something has gone wrong. + return; + } + + const intervalId = setInterval(async () => { + if (success) { + // Stop retries upon success + setRetryCount(0); + return; + } + + await checkForTransactions(); + + // Increment retry count for next attempt (regardless of success/error) + setRetryCount(prev => prev + 1); + }, 1000); + + return () => { + clearInterval(intervalId); + }; + }, [retryCount, success]); + return ( Date: Thu, 19 Feb 2026 10:59:40 +0100 Subject: [PATCH 2/3] Fix the interface of getAddressDetails() This require https://github.com/PayButton/paybutton-server/pull/1108 to work properly. The server PR fixes the returned type of getAddressDetails so we don't need to convert anymore. Test Plan: Run against the updated server, add some logs if desired, and check the payment is successful on mobile. --- .../lib/components/Widget/WidgetContainer.tsx | 22 ++----------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/react/lib/components/Widget/WidgetContainer.tsx b/react/lib/components/Widget/WidgetContainer.tsx index d8724b5d..1033031f 100644 --- a/react/lib/components/Widget/WidgetContainer.tsx +++ b/react/lib/components/Widget/WidgetContainer.tsx @@ -19,7 +19,6 @@ import { shouldTriggerOnSuccess, isPropsTrue, DEFAULT_DONATION_RATE, - parseOpReturnData, } from '../../util'; import { getAddressDetails } from '../../util/api-client'; @@ -294,26 +293,9 @@ export const WidgetContainer: React.FunctionComponent = // Save time by only checking the last few transactions const recentTxs = history.slice(0, 5); - for (const apiTx of recentTxs) { - // FIXME: the getAddressDetails API returns an object that is NOT - // the same as the Transaction[] type, so we need to convert it - const parsedOpReturn = apiTx.opReturn - ? parseOpReturnData(apiTx.opReturn) - : {}; - - const tx: Transaction = { - hash: apiTx.hash, - amount: apiTx.amount, - paymentId: parsedOpReturn.paymentId ?? '', - confirmed: apiTx.confirmed, - message: parsedOpReturn.message ?? '', - timestamp: apiTx.timestamp, - address: (apiTx.address as unknown as { address: string }).address, - rawMessage: parsedOpReturn.rawMessage, - opReturn: apiTx.opReturn, - }; + recentTxs.forEach(tx => { handleNewTransaction(tx); - } + }); return true; } catch (error) { // Failed to fetch history, there is no point in retrying From 660ad5065ed2ce1580148c2828d8c6d40d5304c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Estev=C3=A3o?= Date: Thu, 19 Feb 2026 20:49:57 -0300 Subject: [PATCH 3/3] refactor: hardcoded values into constants --- react/lib/components/Widget/WidgetContainer.tsx | 10 +++++++--- react/lib/util/constants.ts | 4 ++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/react/lib/components/Widget/WidgetContainer.tsx b/react/lib/components/Widget/WidgetContainer.tsx index 1033031f..18615cfb 100644 --- a/react/lib/components/Widget/WidgetContainer.tsx +++ b/react/lib/components/Widget/WidgetContainer.tsx @@ -19,6 +19,9 @@ import { shouldTriggerOnSuccess, isPropsTrue, DEFAULT_DONATION_RATE, + POLL_TX_HISTORY_LOOKBACK, + POLL_REQUEST_DELAY, + POLL_MAX_RETRY, } from '../../util'; import { getAddressDetails } from '../../util/api-client'; @@ -291,7 +294,7 @@ export const WidgetContainer: React.FunctionComponent = try { const history = await getAddressDetails(to, apiBaseUrl); // Save time by only checking the last few transactions - const recentTxs = history.slice(0, 5); + const recentTxs = history.slice(0, POLL_TX_HISTORY_LOOKBACK); recentTxs.forEach(tx => { handleNewTransaction(tx); @@ -365,7 +368,8 @@ export const WidgetContainer: React.FunctionComponent = // Retry mechanism: check every second if payment hasn't succeeded yet useEffect(() => { - if (retryCount === 0 || success || retryCount >= 5) { + + if (retryCount === 0 || success || retryCount >= POLL_MAX_RETRY) { // Retry up to 5 times or until the payment succeeds. If the payment tx // is not found within this time period, something has gone wrong. return; @@ -382,7 +386,7 @@ export const WidgetContainer: React.FunctionComponent = // Increment retry count for next attempt (regardless of success/error) setRetryCount(prev => prev + 1); - }, 1000); + }, POLL_REQUEST_DELAY); return () => { clearInterval(intervalId); diff --git a/react/lib/util/constants.ts b/react/lib/util/constants.ts index fcdf68f4..a2551eb2 100644 --- a/react/lib/util/constants.ts +++ b/react/lib/util/constants.ts @@ -38,3 +38,7 @@ export const DEFAULT_MINIMUM_DONATION_AMOUNT: { [key: string]: number } = { BCH: 0.00001000, XEC: 10, }; + +export const POLL_TX_HISTORY_LOOKBACK = 5 // request last 5 txs +export const POLL_REQUEST_DELAY = 1000 // 1s +export const POLL_MAX_RETRY = 5