From 3894e4c09152ec6bad2e2cda71aa467c04e6e972 Mon Sep 17 00:00:00 2001 From: David Klakurka Date: Sat, 2 Aug 2025 22:03:04 -0700 Subject: [PATCH 1/4] Switch to the new cashtab-connect library (simpler, more capable, not maintained by us). --- react/lib/components/Widget/Widget.tsx | 21 +----- react/lib/util/api-client.ts | 8 --- react/lib/util/cashtab.ts | 95 ++++++++++++++++++++++++++ react/lib/util/index.ts | 1 + react/package.json | 1 + 5 files changed, 100 insertions(+), 26 deletions(-) create mode 100644 react/lib/util/cashtab.ts diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index 4e4c7019..225809ca 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -18,7 +18,7 @@ import { getAddressBalance, isFiat, Transaction, - getCashtabProviderStatus, + openCashtabPayment, DECIMALS, CurrencyObject, getCurrencyObject, @@ -693,24 +693,9 @@ export const Widget: React.FunctionComponent = props => { } }, [totalReceived, currency, goalAmount, price, hasPrice, contributionOffset]); - const handleButtonClick = () => { + const handleButtonClick = async () => { if (thisAddressType === 'XEC') { - const hasExtension = getCashtabProviderStatus(); - if (!hasExtension) { - const webUrl = `https://cashtab.com/#/send?bip21=${url}`; - window.open(webUrl, '_blank'); - } else { - return window.postMessage( - { - type: 'FROM_PAGE', - text: 'Cashtab', - txInfo: { - bip21: url - }, - }, - '*', - ); - } + await openCashtabPayment(url); } else { window.location.href = url; } diff --git a/react/lib/util/api-client.ts b/react/lib/util/api-client.ts index 65134121..cdcb1e13 100644 --- a/react/lib/util/api-client.ts +++ b/react/lib/util/api-client.ts @@ -97,11 +97,3 @@ export default { getAddressBalance, }; -export const getCashtabProviderStatus = () => { - const windowAny = window as any - if (window && windowAny.bitcoinAbc && windowAny.bitcoinAbc === 'cashtab') { - return true; - } - return false; -}; - diff --git a/react/lib/util/cashtab.ts b/react/lib/util/cashtab.ts new file mode 100644 index 00000000..f51a60cc --- /dev/null +++ b/react/lib/util/cashtab.ts @@ -0,0 +1,95 @@ +import { + CashtabConnect, + CashtabExtensionUnavailableError, + CashtabAddressDeniedError, + CashtabTimeoutError +} from 'cashtab-connect'; + +// Create a single instance to be reused throughout the app +const cashtab = new CashtabConnect(); + +/** + * Check if the Cashtab extension is available + * @returns Promise - true if extension is available, false otherwise + */ +export const getCashtabProviderStatus = async (): Promise => { + try { + return await cashtab.isExtensionAvailable(); + } catch (error) { + return false; + } +}; + +/** + * Wait for the Cashtab extension to become available + * @param timeout - Maximum time to wait in milliseconds (default: 3000) + * @returns Promise that resolves when extension is available or rejects on timeout + */ +export const waitForCashtabExtension = async (timeout?: number): Promise => { + return cashtab.waitForExtension(timeout); +}; + +/** + * Request the user's eCash address from their Cashtab wallet + * @returns Promise - The user's address + * @throws {CashtabExtensionUnavailableError} When the Cashtab extension is not available + * @throws {CashtabAddressDeniedError} When the user denies the address request + * @throws {CashtabTimeoutError} When the request times out + */ +export const requestCashtabAddress = async (): Promise => { + return cashtab.requestAddress(); +}; + +/** + * Send XEC to an address using Cashtab + * @param address - Recipient's eCash address + * @param amount - Amount to send in XEC + * @throws {CashtabExtensionUnavailableError} When the Cashtab extension is not available + */ +export const sendXecWithCashtab = async (address: string, amount: string | number): Promise => { + return cashtab.sendXec(address, amount); +}; + +/** + * Open Cashtab with a BIP21 payment URL + * @param bip21Url - The BIP21 formatted payment URL + * @param fallbackUrl - Optional fallback URL if extension is not available + */ +export const openCashtabPayment = async (bip21Url: string, fallbackUrl?: string): Promise => { + try { + const isAvailable = await getCashtabProviderStatus(); + + if (isAvailable) { + // For BIP21 URLs, we need to parse them to extract address and amount + // The cashtab-connect library expects separate address and amount parameters + const url = new URL(bip21Url); + const address = url.pathname; + const amount = url.searchParams.get('amount'); + + if (amount) { + // If we have an amount, use the sendXec method + await sendXecWithCashtab(address, amount); + } else { + // If no amount, fall back to opening web Cashtab with the full BIP21 URL + const webUrl = fallbackUrl || `https://cashtab.com/#/send?bip21=${encodeURIComponent(bip21Url)}`; + window.open(webUrl, '_blank'); + } + } else { + // Extension not available, open web Cashtab + const webUrl = fallbackUrl || `https://cashtab.com/#/send?bip21=${encodeURIComponent(bip21Url)}`; + window.open(webUrl, '_blank'); + } + } catch (error) { + console.warn('Cashtab payment failed, falling back to web interface:', error); + // If extension interaction fails, fall back to web Cashtab + const webUrl = fallbackUrl || `https://cashtab.com/#/send?bip21=${encodeURIComponent(bip21Url)}`; + window.open(webUrl, '_blank'); + } +}; + +// Export error types for consumers to handle specific errors +export { + CashtabExtensionUnavailableError, + CashtabAddressDeniedError, + CashtabTimeoutError +}; diff --git a/react/lib/util/index.ts b/react/lib/util/index.ts index 05af8e8f..11e5cf6e 100644 --- a/react/lib/util/index.ts +++ b/react/lib/util/index.ts @@ -1,5 +1,6 @@ export * from './address'; export * from './api-client'; +export * from './cashtab'; export * from './constants'; export * from './format'; export * from './opReturn'; diff --git a/react/package.json b/react/package.json index 41d93149..c65bc5e7 100644 --- a/react/package.json +++ b/react/package.json @@ -88,6 +88,7 @@ "@types/jest": "^29.5.11", "axios": "1.6.5", "bignumber.js": "9.0.2", + "cashtab-connect": "^1.1.0", "copy-to-clipboard": "3.3.3", "crypto-js": "^4.2.0", "jest": "^29.7.0", From b1e0e126cc190467b728adea9fd9af1ceed4354e Mon Sep 17 00:00:00 2001 From: David Klakurka Date: Sat, 2 Aug 2025 22:16:12 -0700 Subject: [PATCH 2/4] Do nothing if user rejects broadcasting a transaction. --- react/lib/util/cashtab.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/react/lib/util/cashtab.ts b/react/lib/util/cashtab.ts index f51a60cc..5183916b 100644 --- a/react/lib/util/cashtab.ts +++ b/react/lib/util/cashtab.ts @@ -80,7 +80,12 @@ export const openCashtabPayment = async (bip21Url: string, fallbackUrl?: string) window.open(webUrl, '_blank'); } } catch (error) { - console.warn('Cashtab payment failed, falling back to web interface:', error); + if (error instanceof CashtabAddressDeniedError) { + // User rejected the transaction - do nothing for now + // This case is handled here in case we want to add specific behavior in the future + return; + } + // If extension interaction fails, fall back to web Cashtab const webUrl = fallbackUrl || `https://cashtab.com/#/send?bip21=${encodeURIComponent(bip21Url)}`; window.open(webUrl, '_blank'); From a68e6be103888c29b94af0c474e6ae804a8008b6 Mon Sep 17 00:00:00 2001 From: David Klakurka Date: Sun, 3 Aug 2025 14:56:21 -0700 Subject: [PATCH 3/4] Updated Cashtab extension check to happen only once and at page load. --- react/lib/components/Widget/Widget.tsx | 27 ++++++++++++- react/lib/util/cashtab.ts | 53 +++++++++++++++++++++++--- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index 225809ca..440f140a 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -19,6 +19,7 @@ import { isFiat, Transaction, openCashtabPayment, + initializeCashtabStatus, DECIMALS, CurrencyObject, getCurrencyObject, @@ -415,6 +416,7 @@ export const Widget: React.FunctionComponent = props => { const [text, setText] = useState(`Send any amount of ${thisAddressType}`); const [widgetButtonText, setWidgetButtonText] = useState('Send Payment'); const [opReturn, setOpReturn] = useState(); + const [isCashtabAvailable, setIsCashtabAvailable] = useState(false); const [isAboveMinimumAltpaymentAmount, setIsAboveMinimumAltpaymentAmount] = useState(null); @@ -460,6 +462,21 @@ export const Widget: React.FunctionComponent = props => { setHasPrice(price !== undefined && price > 0) }, [price]) + // Initialize Cashtab extension status on component mount + useEffect(() => { + const initCashtab = async () => { + try { + const isAvailable = await initializeCashtabStatus(); + setIsCashtabAvailable(isAvailable); + } catch (error) { + // If initialization fails, assume extension is not available + setIsCashtabAvailable(false); + } + }; + + initCashtab(); + }, []); // Run only once on mount + useEffect(() => { (async () => { if (isChild !== true) { @@ -597,7 +614,13 @@ export const Widget: React.FunctionComponent = props => { let url; setThisAddressType(thisAddressType); - setWidgetButtonText(`Send with ${thisAddressType} wallet`); + + // Update button text based on address type and Cashtab availability + if (thisAddressType === 'XEC' && isCashtabAvailable) { + setWidgetButtonText('Send with Cashtab'); + } else { + setWidgetButtonText(`Send with ${thisAddressType} wallet`); + } if (thisCurrencyObject && hasPrice) { const convertedAmount = thisCurrencyObject.float / price @@ -628,7 +651,7 @@ export const Widget: React.FunctionComponent = props => { } setUrl(url ?? ""); } - }, [to, thisCurrencyObject, price, thisAmount, opReturn, hasPrice]); + }, [to, thisCurrencyObject, price, thisAmount, opReturn, hasPrice, isCashtabAvailable]); useEffect(() => { try { diff --git a/react/lib/util/cashtab.ts b/react/lib/util/cashtab.ts index 5183916b..1ee47434 100644 --- a/react/lib/util/cashtab.ts +++ b/react/lib/util/cashtab.ts @@ -8,16 +8,59 @@ import { // Create a single instance to be reused throughout the app const cashtab = new CashtabConnect(); +// Cache for extension status to avoid multiple checks +let extensionStatusCache: boolean | null = null; +let extensionStatusPromise: Promise | null = null; + /** - * Check if the Cashtab extension is available + * Check if the Cashtab extension is available (with caching) + * This function caches the result to avoid multiple extension checks per page load * @returns Promise - true if extension is available, false otherwise */ export const getCashtabProviderStatus = async (): Promise => { - try { - return await cashtab.isExtensionAvailable(); - } catch (error) { - return false; + // Return cached result if available + if (extensionStatusCache !== null) { + return extensionStatusCache; + } + + // If a check is already in progress, wait for it + if (extensionStatusPromise !== null) { + return extensionStatusPromise; } + + // Start a new check and cache the promise + extensionStatusPromise = (async () => { + try { + const isAvailable = await cashtab.isExtensionAvailable(); + extensionStatusCache = isAvailable; + return isAvailable; + } catch (error) { + extensionStatusCache = false; + return false; + } finally { + // Clear the promise so future calls can make a fresh check if needed + extensionStatusPromise = null; + } + })(); + + return extensionStatusPromise; +}; + +/** + * Clear the cached extension status (useful for testing or if extension state changes) + */ +export const clearCashtabStatusCache = (): void => { + extensionStatusCache = null; + extensionStatusPromise = null; +}; + +/** + * Initialize Cashtab status check (call this on page load to cache extension status) + * This function starts the extension check early to have the result ready when needed + * @returns Promise - true if extension is available, false otherwise + */ +export const initializeCashtabStatus = async (): Promise => { + return getCashtabProviderStatus(); }; /** From bd904d2829c54e0a60fd2225486de39269096df6 Mon Sep 17 00:00:00 2001 From: David Klakurka Date: Fri, 8 Aug 2025 19:18:30 -0700 Subject: [PATCH 4/4] Cleaned up code comments. --- react/lib/components/Widget/Widget.tsx | 5 +---- react/lib/util/cashtab.ts | 26 +------------------------- 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/react/lib/components/Widget/Widget.tsx b/react/lib/components/Widget/Widget.tsx index 440f140a..74bfeeb2 100644 --- a/react/lib/components/Widget/Widget.tsx +++ b/react/lib/components/Widget/Widget.tsx @@ -462,20 +462,18 @@ export const Widget: React.FunctionComponent = props => { setHasPrice(price !== undefined && price > 0) }, [price]) - // Initialize Cashtab extension status on component mount useEffect(() => { const initCashtab = async () => { try { const isAvailable = await initializeCashtabStatus(); setIsCashtabAvailable(isAvailable); } catch (error) { - // If initialization fails, assume extension is not available setIsCashtabAvailable(false); } }; initCashtab(); - }, []); // Run only once on mount + }, []); useEffect(() => { (async () => { @@ -615,7 +613,6 @@ export const Widget: React.FunctionComponent = props => { setThisAddressType(thisAddressType); - // Update button text based on address type and Cashtab availability if (thisAddressType === 'XEC' && isCashtabAvailable) { setWidgetButtonText('Send with Cashtab'); } else { diff --git a/react/lib/util/cashtab.ts b/react/lib/util/cashtab.ts index 1ee47434..fb0a64cd 100644 --- a/react/lib/util/cashtab.ts +++ b/react/lib/util/cashtab.ts @@ -5,7 +5,6 @@ import { CashtabTimeoutError } from 'cashtab-connect'; -// Create a single instance to be reused throughout the app const cashtab = new CashtabConnect(); // Cache for extension status to avoid multiple checks @@ -28,7 +27,6 @@ export const getCashtabProviderStatus = async (): Promise => { return extensionStatusPromise; } - // Start a new check and cache the promise extensionStatusPromise = (async () => { try { const isAvailable = await cashtab.isExtensionAvailable(); @@ -54,20 +52,11 @@ export const clearCashtabStatusCache = (): void => { extensionStatusPromise = null; }; -/** - * Initialize Cashtab status check (call this on page load to cache extension status) - * This function starts the extension check early to have the result ready when needed - * @returns Promise - true if extension is available, false otherwise - */ + export const initializeCashtabStatus = async (): Promise => { return getCashtabProviderStatus(); }; -/** - * Wait for the Cashtab extension to become available - * @param timeout - Maximum time to wait in milliseconds (default: 3000) - * @returns Promise that resolves when extension is available or rejects on timeout - */ export const waitForCashtabExtension = async (timeout?: number): Promise => { return cashtab.waitForExtension(timeout); }; @@ -83,12 +72,6 @@ export const requestCashtabAddress = async (): Promise => { return cashtab.requestAddress(); }; -/** - * Send XEC to an address using Cashtab - * @param address - Recipient's eCash address - * @param amount - Amount to send in XEC - * @throws {CashtabExtensionUnavailableError} When the Cashtab extension is not available - */ export const sendXecWithCashtab = async (address: string, amount: string | number): Promise => { return cashtab.sendXec(address, amount); }; @@ -103,22 +86,17 @@ export const openCashtabPayment = async (bip21Url: string, fallbackUrl?: string) const isAvailable = await getCashtabProviderStatus(); if (isAvailable) { - // For BIP21 URLs, we need to parse them to extract address and amount - // The cashtab-connect library expects separate address and amount parameters const url = new URL(bip21Url); const address = url.pathname; const amount = url.searchParams.get('amount'); if (amount) { - // If we have an amount, use the sendXec method await sendXecWithCashtab(address, amount); } else { - // If no amount, fall back to opening web Cashtab with the full BIP21 URL const webUrl = fallbackUrl || `https://cashtab.com/#/send?bip21=${encodeURIComponent(bip21Url)}`; window.open(webUrl, '_blank'); } } else { - // Extension not available, open web Cashtab const webUrl = fallbackUrl || `https://cashtab.com/#/send?bip21=${encodeURIComponent(bip21Url)}`; window.open(webUrl, '_blank'); } @@ -129,13 +107,11 @@ export const openCashtabPayment = async (bip21Url: string, fallbackUrl?: string) return; } - // If extension interaction fails, fall back to web Cashtab const webUrl = fallbackUrl || `https://cashtab.com/#/send?bip21=${encodeURIComponent(bip21Url)}`; window.open(webUrl, '_blank'); } }; -// Export error types for consumers to handle specific errors export { CashtabExtensionUnavailableError, CashtabAddressDeniedError,