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/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index 4b2c2796..e587e8c4 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 @@ -593,6 +599,48 @@ 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) + if (!isChronikWsEndpoint(thisTxsSocket)) { + return; + } + + if (document.hidden) { + // Page went to background - suspend WebSocket + try { + thisTxsSocket.pause(); + } catch (error) { + console.error('Error pausing WebSocket:', error); + } + } else { + // Page came to foreground - resume WebSocket + try { + await thisTxsSocket.resume(); + } catch (error) { + console.error('Error resuming WebSocket:', error); + } + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [isChild, thisTxsSocket]) + const tradeWithAltpayment = () => { setThisUseAltpayment(true) } 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/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/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 diff --git a/react/lib/util/socket.ts b/react/lib/util/socket.ts index 2961ff3b..eff8b088 100644 --- a/react/lib/util/socket.ts +++ b/react/lib/util/socket.ts @@ -2,47 +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); - } - } - }); -}; +import { WsEndpoint } from 'chronik-client'; interface AltpaymentListenerParams { addressType: string @@ -115,7 +78,7 @@ export const setupAltpaymentSocket = async (params: SetupAltpaymentSocketParams) interface SetupTxsSocketParams { address: string - txsSocket?: Socket + txsSocket?: Socket | WsEndpoint apiBaseUrl?: string wsBaseUrl?: string setTxsSocket: Function @@ -124,20 +87,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}`); 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"