From 09503100f9a215ddedb7d4ffa22f332315964b7c Mon Sep 17 00:00:00 2001 From: Fabien Date: Thu, 19 Feb 2026 15:09:30 +0100 Subject: [PATCH 1/5] Update to latest chronik-client Drop the legacy chronik-client-cashtokens and use the latest chronik-client version. Test Plan: yarn test --- paybutton/yarn.lock | 38 +++++++++----------------------------- react/lib/util/chronik.ts | 2 +- react/package.json | 4 ++-- react/yarn.lock | 21 +++++++++++---------- 4 files changed, 23 insertions(+), 42 deletions(-) diff --git a/paybutton/yarn.lock b/paybutton/yarn.lock index 222c9215..8541d8e4 100644 --- a/paybutton/yarn.lock +++ b/paybutton/yarn.lock @@ -748,29 +748,8 @@ react-is "^19.1.1" "@paybutton/react@link:../react": - version "5.2.1" - dependencies: - "@emotion/react" "^11.14.0" - "@emotion/styled" "^11.14.1" - "@mui/material" "^7.3.4" - "@types/crypto-js" "^4.2.1" - "@types/jest" "^29.5.11" - axios "1.12.0" - bignumber.js "9.0.2" - cashtab-connect "^1.1.0" - chronik-client-cashtokens "^3.4.0" - copy-to-clipboard "3.3.3" - crypto-js "^4.2.0" - decimal.js "^10.6.0" - ecashaddrjs "^2.0.0" - jest "^29.7.0" - lodash "4.17.21" - notistack "3.0.0" - qrcode.react "3" - react-number-format "^5.4.4" - socket.io-client "4.7.4" - ts-jest "^29.4.5" - xecaddrjs "^0.0.1" + version "0.0.0" + uid "" "@popperjs/core@^2.11.8": version "2.11.8" @@ -1755,15 +1734,16 @@ chrome-trace-event@^1.0.2: resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz#05bffd7ff928465093314708c93bdfa9bd1f0f5b" integrity sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ== -chronik-client-cashtokens@^3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/chronik-client-cashtokens/-/chronik-client-cashtokens-3.4.0.tgz#0c958c72c119867d924c0c446e0765381fd1b792" - integrity sha512-D2G8j+dhOlG0wr+DtgnMHHwCYzpa/XCzNvHhkkAfOOL3OaAoXONMxhbqx+N1y7B0L5ot69y8z/8KOEz9ARL3hg== +chronik-client@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/chronik-client/-/chronik-client-4.1.0.tgz#f49a07565064335f1d576230bf77546a9bc29e8b" + integrity sha512-RA7gXMTmWW3ek29ACvHGfcJxGUBMxtcvMCCV7EJLywYCCYyCDmjdcTVIHEqEFF0QHj6F5PJi5nvA65cAtBnr5Q== dependencies: "@types/ws" "^8.2.1" axios "^1.6.3" - ecashaddrjs "2.0.0" + ecashaddrjs "^2.0.0" isomorphic-ws "^4.0.1" + long "^4.0.0" protobufjs "^6.8.8" ws "^8.3.0" @@ -2006,7 +1986,7 @@ dunder-proto@^1.0.1: es-errors "^1.3.0" gopd "^1.2.0" -ecashaddrjs@2.0.0, ecashaddrjs@^1.0.7, ecashaddrjs@^2.0.0: +ecashaddrjs@^1.0.7, ecashaddrjs@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ecashaddrjs/-/ecashaddrjs-2.0.0.tgz#d45ede7fb6168815dbcf664b8e0a6872e485d874" integrity sha512-EvK1V4D3+nIEoD0ggy/b0F4lW39/72R9aOs/scm6kxMVuXu16btc+H74eQv7okNfXaQWKgolEekZkQ6wfcMMLw== diff --git a/react/lib/util/chronik.ts b/react/lib/util/chronik.ts index 7714d257..1db85dae 100644 --- a/react/lib/util/chronik.ts +++ b/react/lib/util/chronik.ts @@ -1,4 +1,4 @@ -import { ChronikClient, WsEndpoint, Tx, ConnectionStrategy} from 'chronik-client-cashtokens'; +import { ChronikClient, WsEndpoint, Tx, ConnectionStrategy} from 'chronik-client'; import { encodeCashAddress, decodeCashAddress } from 'ecashaddrjs' import { AddressType } from 'ecashaddrjs/dist/types' import xecaddr from 'xecaddrjs' diff --git a/react/package.json b/react/package.json index e5e3e37e..b10a1217 100644 --- a/react/package.json +++ b/react/package.json @@ -95,7 +95,7 @@ "axios": "1.12.0", "bignumber.js": "9.0.2", "cashtab-connect": "^1.1.0", - "chronik-client-cashtokens": "^3.4.0", + "chronik-client": "^4.1.0", "copy-to-clipboard": "3.3.3", "crypto-js": "^4.2.0", "decimal.js": "^10.6.0", @@ -118,7 +118,7 @@ "resolutions": { "@types/react": "18.3.26", "@types/react-dom": "18.3.0", - "chronik-client-cashtokens/ecashaddrjs": "^2.0.0", + "chronik-client/ecashaddrjs": "^2.0.0", "webpack": "^5.0.0", "@babel/plugin-syntax-flow": "^7.14.5", "@babel/plugin-transform-react-jsx": "^7.14.9", diff --git a/react/yarn.lock b/react/yarn.lock index 37f6ae46..3d46d5ea 100644 --- a/react/yarn.lock +++ b/react/yarn.lock @@ -5984,15 +5984,16 @@ chrome-trace-event@^1.0.2: resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz#05bffd7ff928465093314708c93bdfa9bd1f0f5b" integrity sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ== -chronik-client-cashtokens@^3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/chronik-client-cashtokens/-/chronik-client-cashtokens-3.4.0.tgz#0c958c72c119867d924c0c446e0765381fd1b792" - integrity sha512-D2G8j+dhOlG0wr+DtgnMHHwCYzpa/XCzNvHhkkAfOOL3OaAoXONMxhbqx+N1y7B0L5ot69y8z/8KOEz9ARL3hg== +chronik-client@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/chronik-client/-/chronik-client-4.1.0.tgz#f49a07565064335f1d576230bf77546a9bc29e8b" + integrity sha512-RA7gXMTmWW3ek29ACvHGfcJxGUBMxtcvMCCV7EJLywYCCYyCDmjdcTVIHEqEFF0QHj6F5PJi5nvA65cAtBnr5Q== dependencies: "@types/ws" "^8.2.1" axios "^1.6.3" - ecashaddrjs "2.0.0" + ecashaddrjs "^2.0.0" isomorphic-ws "^4.0.1" + long "^4.0.0" protobufjs "^6.8.8" ws "^8.3.0" @@ -7079,11 +7080,6 @@ eastasianwidth@^0.2.0: resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== -ecashaddrjs@2.0.0, ecashaddrjs@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ecashaddrjs/-/ecashaddrjs-2.0.0.tgz#d45ede7fb6168815dbcf664b8e0a6872e485d874" - integrity sha512-EvK1V4D3+nIEoD0ggy/b0F4lW39/72R9aOs/scm6kxMVuXu16btc+H74eQv7okNfXaQWKgolEekZkQ6wfcMMLw== - ecashaddrjs@^1.0.7: version "1.6.2" resolved "https://registry.yarnpkg.com/ecashaddrjs/-/ecashaddrjs-1.6.2.tgz#9fec1131bc006a874e4c2fa589e6fc880c40745e" @@ -7092,6 +7088,11 @@ ecashaddrjs@^1.0.7: big-integer "1.6.36" bs58check "^3.0.1" +ecashaddrjs@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ecashaddrjs/-/ecashaddrjs-2.0.0.tgz#d45ede7fb6168815dbcf664b8e0a6872e485d874" + integrity sha512-EvK1V4D3+nIEoD0ggy/b0F4lW39/72R9aOs/scm6kxMVuXu16btc+H74eQv7okNfXaQWKgolEekZkQ6wfcMMLw== + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" From e92b7f637515fbca83810bc8b0be2b4292932189 Mon Sep 17 00:00:00 2001 From: Fabien Date: Thu, 19 Feb 2026 15:30:13 +0100 Subject: [PATCH 2/5] Properly pause/resume chronik when switching to/from background/foreground Use the dedicated pause() and resume() methods of WsEndpoint for this. This prevents the chronik client from failing when moving to background on mobile due to the lack of internet connection. Test Plan: On mobile, show a widget and move the browser to background. Check in the console that chronik no longer lose connection (an error is raised before this patch after a few seconds). --- react/lib/components/Widget/Widget.tsx | 45 ++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index 4b2c2796..e721931e 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -593,6 +593,51 @@ export const Widget: React.FunctionComponent = props => { } }, [to, thisUseAltpayment]) + // Suspend/resume Chronik WebSocket based on page visibility + useEffect(() => { + if (typeof document === 'undefined' || isChild === true) { + return; + } + + const handleVisibilityChange = async () => { + if (!thisTxsSocket) { + // Not initialized yet + return; + } + + // Check if this is a Chronik WsEndpoint (has pause/resume methods) + // vs a socket.io Socket (doesn't have these methods) + const isChronikWs = typeof (thisTxsSocket as any).pause === 'function' && + typeof (thisTxsSocket as any).resume === 'function'; + + if (!isChronikWs) { + return; + } + + if (document.hidden) { + // Page went to background - suspend WebSocket + try { + (thisTxsSocket as any).pause(); + } catch (error) { + console.error('Error pausing WebSocket:', error); + } + } else { + // Page came to foreground - resume WebSocket + try { + await (thisTxsSocket as any).resume(); + } catch (error) { + console.error('Error resuming WebSocket:', error); + } + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [isChild, thisTxsSocket]) + const tradeWithAltpayment = () => { setThisUseAltpayment(true) } From a860ae3e900bbac61574953f65c30c13f3e92849 Mon Sep 17 00:00:00 2001 From: Fabien Date: Thu, 19 Feb 2026 15:45:06 +0100 Subject: [PATCH 3/5] Remove dead code in socket.ts This is in the way for updating the type checking for chronik websocket. There is no change in behavior. --- react/lib/util/socket.ts | 54 +--------------------------------------- 1 file changed, 1 insertion(+), 53 deletions(-) diff --git a/react/lib/util/socket.ts b/react/lib/util/socket.ts index 2961ff3b..0908b2a2 100644 --- a/react/lib/util/socket.ts +++ b/react/lib/util/socket.ts @@ -2,48 +2,10 @@ import { io, Socket } from 'socket.io-client'; import { AltpaymentCoin, AltpaymentError, AltpaymentPair, AltpaymentShift } from '../altpayment'; import config from '../paybutton-config.json'; -import { BroadcastTxData, CheckSuccessInfo, Transaction } from './types'; -import { getAddressDetails } from './api-client'; -import { getAddressPrefixed } from './address'; +import { CheckSuccessInfo, Transaction } from './types'; import { shouldTriggerOnSuccess } from './validate'; import { initializeChronikWebsocket } from './chronik'; -const txsListener = (txsSocket: Socket, setNewTxs: Function, setDialogOpen?: Function, checkSuccessInfo?: CheckSuccessInfo): void => { - txsSocket.on('incoming-txs', (broadcastedTxData: BroadcastTxData) => { - const unconfirmedTxs = broadcastedTxData.txs.filter( - tx => tx.confirmed === false, - ); - if ( - broadcastedTxData.messageType === 'NewTx' && - unconfirmedTxs.length !== 0 - ) { - if (setDialogOpen !== undefined && checkSuccessInfo !== undefined) { - for (const tx of unconfirmedTxs) { - if (shouldTriggerOnSuccess( - tx, - checkSuccessInfo.currency, - checkSuccessInfo.price, - checkSuccessInfo.randomSatoshis, - checkSuccessInfo.disablePaymentId, - checkSuccessInfo.expectedPaymentId, - checkSuccessInfo.expectedAmount, - checkSuccessInfo.expectedOpReturn, - checkSuccessInfo.currencyObj - )) { - setDialogOpen(true) - setTimeout(() => { - setNewTxs(unconfirmedTxs); - }, 700); - break - } - } - } else { - setNewTxs(unconfirmedTxs); - } - } - }); -}; - interface AltpaymentListenerParams { addressType: string altpaymentSocket: Socket @@ -124,20 +86,6 @@ interface SetupTxsSocketParams { checkSuccessInfo?: CheckSuccessInfo } -export const setupTxsSocket = async (params: SetupTxsSocketParams): Promise => { - void getAddressDetails(params.address, params.apiBaseUrl); - if (params.txsSocket !== undefined) { - params.txsSocket.disconnect(); - params.setTxsSocket(undefined); - } - const newSocket = io(`${params.wsBaseUrl ?? config.wsBaseUrl}/addresses`, { - forceNew: true, - query: { addresses: [getAddressPrefixed(params.address)] }, - }); - params.setTxsSocket(newSocket); - txsListener(newSocket, params.setNewTxs, params.setDialogOpen, params.checkSuccessInfo); -} - export const setupChronikWebSocket = async (params: SetupTxsSocketParams): Promise => { if (params.txsSocket !== undefined) { console.log(`Closing existing Chronik WebSocket for address: ${params.address}`); From 1ab0422bf64ed3739c041f9926bf993abe6bf30d Mon Sep 17 00:00:00 2001 From: Fabien Date: Thu, 19 Feb 2026 15:51:45 +0100 Subject: [PATCH 4/5] Use proper typing for the chronik WsEndpoint And use a type guard to ensure we are working on the expected type. There is no change in behavior. --- react/lib/components/Widget/Widget.tsx | 19 +++++++++++-------- react/lib/util/socket.ts | 3 ++- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index e721931e..59f43e00 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -53,6 +53,7 @@ import { MINIMUM_ALTPAYMENT_DOLLAR_AMOUNT, MINIMUM_ALTPAYMENT_CAD_AMOUNT, } from '../../altpayment' +import { WsEndpoint } from 'chronik-client' export interface WidgetProps { @@ -221,10 +222,15 @@ export const Widget: React.FunctionComponent = props => { // websockets if standalone - const [internalTxsSocket, setInternalTxsSocket] = useState(undefined) + const [internalTxsSocket, setInternalTxsSocket] = useState(undefined) const thisTxsSocket = txsSocket ?? internalTxsSocket const setThisTxsSocket = - (setTxsSocket as ((s: Socket | undefined) => void) | undefined) ?? setInternalTxsSocket + (setTxsSocket as ((s: Socket | WsEndpoint | undefined) => void) | undefined) ?? setInternalTxsSocket + + // Type guard to check if socket is a Chronik WsEndpoint + const isChronikWsEndpoint = (socket: Socket | WsEndpoint | undefined): socket is WsEndpoint => { + return socket instanceof WsEndpoint; + }; const [internalNewTxs, setInternalNewTxs] = useState() const thisNewTxs = newTxs ?? internalNewTxs @@ -607,24 +613,21 @@ export const Widget: React.FunctionComponent = props => { // Check if this is a Chronik WsEndpoint (has pause/resume methods) // vs a socket.io Socket (doesn't have these methods) - const isChronikWs = typeof (thisTxsSocket as any).pause === 'function' && - typeof (thisTxsSocket as any).resume === 'function'; - - if (!isChronikWs) { + if (!isChronikWsEndpoint(thisTxsSocket)) { return; } if (document.hidden) { // Page went to background - suspend WebSocket try { - (thisTxsSocket as any).pause(); + thisTxsSocket.pause(); } catch (error) { console.error('Error pausing WebSocket:', error); } } else { // Page came to foreground - resume WebSocket try { - await (thisTxsSocket as any).resume(); + thisTxsSocket.resume(); } catch (error) { console.error('Error resuming WebSocket:', error); } diff --git a/react/lib/util/socket.ts b/react/lib/util/socket.ts index 0908b2a2..eff8b088 100644 --- a/react/lib/util/socket.ts +++ b/react/lib/util/socket.ts @@ -5,6 +5,7 @@ import config from '../paybutton-config.json'; import { CheckSuccessInfo, Transaction } from './types'; import { shouldTriggerOnSuccess } from './validate'; import { initializeChronikWebsocket } from './chronik'; +import { WsEndpoint } from 'chronik-client'; interface AltpaymentListenerParams { addressType: string @@ -77,7 +78,7 @@ export const setupAltpaymentSocket = async (params: SetupAltpaymentSocketParams) interface SetupTxsSocketParams { address: string - txsSocket?: Socket + txsSocket?: Socket | WsEndpoint apiBaseUrl?: string wsBaseUrl?: string setTxsSocket: Function From 947fb629228d9bdd13fab83deb59c8588a15dffd Mon Sep 17 00:00:00 2001 From: Fabien Date: Thu, 19 Feb 2026 16:23:58 +0100 Subject: [PATCH 5/5] Limit the polling when moving the widget to foreground Now that chronik is properly paused and resumed, the retries are no longer necessary: if the event fired before the reconnect, it will be caught by the first polling round and otherwise by the chronik websocket event. We still keep a single retry for the very unlikely case where the polling is not successful but the event fires before the websocket reconnected. A single 2s delay is used for this case and should not be used in practice, it's only a safety net. The timeout is cleared upon success or visibility change, and worst case it's only an extra api call then a no-op. Test Plan: Check payment on mobile still works as expected. --- react/lib/components/Widget/Widget.tsx | 2 +- .../lib/components/Widget/WidgetContainer.tsx | 56 ++++++++----------- react/lib/util/constants.ts | 3 +- 3 files changed, 26 insertions(+), 35 deletions(-) diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index 59f43e00..e587e8c4 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -627,7 +627,7 @@ export const Widget: React.FunctionComponent = props => { } else { // Page came to foreground - resume WebSocket try { - thisTxsSocket.resume(); + await thisTxsSocket.resume(); } catch (error) { console.error('Error resuming WebSocket:', error); } diff --git a/react/lib/components/Widget/WidgetContainer.tsx b/react/lib/components/Widget/WidgetContainer.tsx index 18615cfb..5ef1e373 100644 --- a/react/lib/components/Widget/WidgetContainer.tsx +++ b/react/lib/components/Widget/WidgetContainer.tsx @@ -21,7 +21,6 @@ import { DEFAULT_DONATION_RATE, POLL_TX_HISTORY_LOOKBACK, POLL_REQUEST_DELAY, - POLL_MAX_RETRY, } from '../../util'; import { getAddressDetails } from '../../util/api-client'; @@ -161,7 +160,6 @@ 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); @@ -319,8 +317,15 @@ export const WidgetContainer: React.FunctionComponent = let wasHidden = document.hidden; let hiddenTimestamp = 0; + let retryTimeoutId: NodeJS.Timeout | null = null; const handleVisibilityChange = async () => { + // Clear any pending retry timeout + if (retryTimeoutId) { + clearTimeout(retryTimeoutId); + retryTimeoutId = null; + } + if (document.hidden) { wasHidden = true; hiddenTimestamp = Date.now(); @@ -352,10 +357,20 @@ export const WidgetContainer: React.FunctionComponent = const checkCompleted = await checkForTransactions(); // If check completed successfully but payment hasn't succeeded yet, - // trigger retries. We might be missing the payment transaction. + // Schedule a single retry after 2 seconds. This is only there to handle + // the case where the transaction is discovered after the app has been + // foregrounded, but before the chronik websocket is resumed. 2 seconds + // should be plenty for this case which is not expected to happen under + // normal circumstances. + // Note that we can't check success at this stage because it is captured + // and we would use a stale value. So we run the timeout unconditionally + // and let the useEffect cancel it if success turns true. Worst case it + // does an API call and then it's a no-op. if (checkCompleted && !success) { - // Start retries - setRetryCount(1); + retryTimeoutId = setTimeout(async () => { + await checkForTransactions(); + retryTimeoutId = null; + }, POLL_REQUEST_DELAY); } }; @@ -363,35 +378,12 @@ export const WidgetContainer: React.FunctionComponent = 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 >= 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; - } - - const intervalId = setInterval(async () => { - if (success) { - // Stop retries upon success - setRetryCount(0); - return; + if (retryTimeoutId) { + clearTimeout(retryTimeoutId); + retryTimeoutId = null; } - - await checkForTransactions(); - - // Increment retry count for next attempt (regardless of success/error) - setRetryCount(prev => prev + 1); - }, POLL_REQUEST_DELAY); - - return () => { - clearInterval(intervalId); }; - }, [retryCount, success]); + }, [to, thisPaymentId, success, disablePaymentId, checkForTransactions]); return ( diff --git a/react/lib/util/constants.ts b/react/lib/util/constants.ts index a2551eb2..bda79847 100644 --- a/react/lib/util/constants.ts +++ b/react/lib/util/constants.ts @@ -40,5 +40,4 @@ export const DEFAULT_MINIMUM_DONATION_AMOUNT: { [key: string]: number } = { }; export const POLL_TX_HISTORY_LOOKBACK = 5 // request last 5 txs -export const POLL_REQUEST_DELAY = 1000 // 1s -export const POLL_MAX_RETRY = 5 +export const POLL_REQUEST_DELAY = 2000 // 2s