diff --git a/.changeset/cyan-nails-heal.md b/.changeset/cyan-nails-heal.md new file mode 100644 index 0000000000..ef309a8b5c --- /dev/null +++ b/.changeset/cyan-nails-heal.md @@ -0,0 +1,5 @@ +--- +'@reown/appkit-pay': patch +--- + +Add new pay method without the need to subscribe to events diff --git a/apps/laboratory/src/components/AppKitPay.tsx b/apps/laboratory/src/components/AppKitPay.tsx index 988c806401..4c3b5efe93 100644 --- a/apps/laboratory/src/components/AppKitPay.tsx +++ b/apps/laboratory/src/components/AppKitPay.tsx @@ -30,19 +30,12 @@ import { import { Card } from '@chakra-ui/react' import type { CaipNetworkId } from '@reown/appkit-common' -import type { - AppKitPayErrorMessage, - Exchange, - PayResult, - PayUrlParams, - PaymentAsset -} from '@reown/appkit-pay' -import { baseETH, baseSepoliaETH, baseUSDC } from '@reown/appkit-pay' +import type { Exchange, PayUrlParams, PaymentAsset } from '@reown/appkit-pay' +import { baseETH, baseSepoliaETH, baseUSDC, pay } from '@reown/appkit-pay' import { type ExchangeBuyStatus, useAvailableExchanges, useExchangeBuyStatus, - usePay, usePayUrlActions } from '@reown/appkit-pay/react' import { solana, solanaDevnet } from '@reown/appkit/networks' @@ -102,27 +95,6 @@ export function AppKitPay() { const { isOpen, onToggle } = useDisclosure() const toast = useChakraToast() - function handleSuccess(resultData: PayResult) { - toast({ - title: 'Transaction successful', - description: resultData, - type: 'success' - }) - } - - function handleError(errorData: AppKitPayErrorMessage) { - toast({ - title: 'Transaction failed', - description: errorData, - type: 'error' - }) - } - - const { open, isPending } = usePay({ - onSuccess: handleSuccess, - onError: handleError - }) - const { data: fetchedExchangesData, isLoading: isLoadingExchanges, @@ -215,12 +187,21 @@ export function AppKitPay() { return } - - await open({ + const result = await pay({ recipient: paymentDetails.recipient, amount: paymentDetails.amount, paymentAsset: paymentDetails.asset }) + + if (result.success) { + toast({ title: 'Payment Succeeded', description: `Tx: ${result.result}`, type: 'success' }) + } else { + toast({ + title: 'Payment Failed', + description: result.error ?? 'Unknown error', + type: 'error' + }) + } } const handleInputChange = useCallback( @@ -518,8 +499,8 @@ export function AppKitPay() { - diff --git a/packages/pay/exports/index.ts b/packages/pay/exports/index.ts index b735378ae8..5150348411 100644 --- a/packages/pay/exports/index.ts +++ b/packages/pay/exports/index.ts @@ -8,7 +8,8 @@ export { getExchanges, getPayResult, getPayError, - getIsPaymentInProgress + getIsPaymentInProgress, + pay } from '../src/client.js' // -- Types ----------------------------------------- // diff --git a/packages/pay/src/client.ts b/packages/pay/src/client.ts index 246afe3ead..1b7e9c1ca5 100644 --- a/packages/pay/src/client.ts +++ b/packages/pay/src/client.ts @@ -1,10 +1,108 @@ import { PayController, type PayControllerState } from './controllers/PayController.js' +import { AppKitPayError, AppKitPayErrorCodes } from './types/errors.js' import type { GetExchangesParams, PayUrlParams, PaymentOptions } from './types/options.js' +import type { PaymentResult } from './types/payment.js' + +// 5 minutes +const PAYMENT_TIMEOUT_MS = 300000 export async function openPay(options: PaymentOptions) { return PayController.handleOpenPay(options) } +export async function pay( + options: PaymentOptions, + timeoutMs = PAYMENT_TIMEOUT_MS +): Promise { + if (timeoutMs <= 0) { + throw new AppKitPayError( + AppKitPayErrorCodes.INVALID_PAYMENT_CONFIG, + 'Timeout must be greater than 0' + ) + } + + try { + await openPay(options) + } catch (error) { + if (error instanceof AppKitPayError) { + throw error + } + throw new AppKitPayError( + AppKitPayErrorCodes.UNABLE_TO_INITIATE_PAYMENT, + (error as Error).message + ) + } + + return new Promise((resolve, reject) => { + let isSettled = false + + const timeoutId = setTimeout(() => { + if (isSettled) { + return + } + isSettled = true + cleanup() + reject(new AppKitPayError(AppKitPayErrorCodes.GENERIC_PAYMENT_ERROR, 'Payment timeout')) + }, timeoutMs) + + function checkAndResolve(): void { + if (isSettled) { + return + } + + const currentPayment = PayController.state.currentPayment + const error = PayController.state.error + const isInProgress = PayController.state.isPaymentInProgress + + if (currentPayment?.status === 'SUCCESS') { + isSettled = true + cleanup() + clearTimeout(timeoutId) + resolve({ + success: true, + result: currentPayment.result + }) + + return + } + + if (currentPayment?.status === 'FAILED') { + isSettled = true + cleanup() + clearTimeout(timeoutId) + resolve({ + success: false, + error: error || 'Payment failed' + }) + + return + } + + if (error && !isInProgress && !currentPayment) { + isSettled = true + cleanup() + clearTimeout(timeoutId) + resolve({ + success: false, + error + }) + } + } + + const unsubscribePayment = subscribeStateKey('currentPayment', checkAndResolve) + const unsubscribeError = subscribeStateKey('error', checkAndResolve) + const unsubscribeProgress = subscribeStateKey('isPaymentInProgress', checkAndResolve) + + const cleanup = createCleanupHandler([ + unsubscribePayment, + unsubscribeError, + unsubscribeProgress + ]) + + checkAndResolve() + }) +} + export function getAvailableExchanges(params?: GetExchangesParams) { return PayController.getAvailableExchanges(params) } @@ -44,3 +142,15 @@ export function subscribeStateKey( ) { return PayController.subscribeKey(key, callback as (value: PayControllerState[K]) => void) } + +function createCleanupHandler(unsubscribeFunctions: Array<() => void>) { + return (): void => { + unsubscribeFunctions.forEach(unsubscribe => { + try { + unsubscribe() + } catch { + // Ignore cleanup errors + } + }) + } +} diff --git a/packages/pay/src/controllers/PayController.ts b/packages/pay/src/controllers/PayController.ts index e0d59c857f..639f2c50d1 100644 --- a/packages/pay/src/controllers/PayController.ts +++ b/packages/pay/src/controllers/PayController.ts @@ -5,6 +5,7 @@ import { type Address, ConstantsUtil, ParseUtil } from '@reown/appkit-common' import { AccountController, ChainController, + ConnectionController, CoreHelperUtil, EventsController, ModalController, @@ -296,19 +297,27 @@ export const PayController = { if (state.isConfigured) { return } - ProviderUtil.subscribeProviders(async _ => { - const provider = ProviderUtil.getProvider(ChainController.state.activeChain) - if (!provider) { - return + + ConnectionController.subscribeKey('connections', connections => { + if (connections.size > 0) { + this.handlePayment() } - await this.handlePayment() }) - AccountController.subscribeKey('caipAddress', async caipAddress => { - if (!caipAddress) { - return + AccountController.subscribeKey('caipAddress', caipAddress => { + const hasWcConnection = ConnectionController.hasAnyConnection( + ConstantsUtil.CONNECTOR_ID.WALLET_CONNECT + ) + if (caipAddress) { + // WalletConnect connections sometimes fail down the line due to state not being updated atomically + if (hasWcConnection) { + setTimeout(() => { + this.handlePayment() + }, 100) + } else { + this.handlePayment() + } } - await this.handlePayment() }) }, async handlePayment() { @@ -323,16 +332,19 @@ export const PayController = { const { chainId, address } = ParseUtil.parseCaipAddress(caipAddress) const chainNamespace = ChainController.state.activeChain + if (!address || !chainId || !chainNamespace) { return } const provider = ProviderUtil.getProvider(chainNamespace) + if (!provider) { return } const caipNetwork = ChainController.state.activeCaipNetwork + if (!caipNetwork) { return } diff --git a/packages/pay/src/types/payment.ts b/packages/pay/src/types/payment.ts new file mode 100644 index 0000000000..1a86830a26 --- /dev/null +++ b/packages/pay/src/types/payment.ts @@ -0,0 +1,5 @@ +export type PaymentResult = { + success: boolean + result?: string + error?: string +}