diff --git a/.changelog/387.feature.md b/.changelog/387.feature.md new file mode 100644 index 00000000..c4e21d52 --- /dev/null +++ b/.changelog/387.feature.md @@ -0,0 +1 @@ +ROFL Paymaster mainnet integration diff --git a/.env b/.env index 1c704b7a..fcfaf24c 100644 --- a/.env +++ b/.env @@ -2,4 +2,3 @@ VITE_WALLET_CONNECT_PROJECT_ID=e3727c2e231abb0791d9cc90b55fb1e1 # VITE_ROFL_APP_BACKEND=http://localhost:8899 VITE_ROFL_APP_BACKEND=https://backend.rofl.app VITE_FATHOM_SIDE_ID=ADZXERYI -VITE_FEATURE_FLAG_PAYMASTER=1 diff --git a/.env.production b/.env.production index 883bf157..ace963a2 100644 --- a/.env.production +++ b/.env.production @@ -1,2 +1 @@ VITE_ROFL_APP_BACKEND=https://backend.rofl.app -VITE_FEATURE_FLAG_PAYMASTER= diff --git a/src/components/rofl-paymaster/TokenLogo/BaseLogo.tsx b/src/components/rofl-paymaster/TokenLogo/BaseLogo.tsx new file mode 100644 index 00000000..9fe0c1d5 --- /dev/null +++ b/src/components/rofl-paymaster/TokenLogo/BaseLogo.tsx @@ -0,0 +1,11 @@ +import type { FC, SVGProps } from 'react' + +export const BaseLogo: FC> = props => ( + + + +) diff --git a/src/components/rofl-paymaster/TokenLogo/ChainLogo.tsx b/src/components/rofl-paymaster/TokenLogo/ChainLogo.tsx new file mode 100644 index 00000000..6c48b2b4 --- /dev/null +++ b/src/components/rofl-paymaster/TokenLogo/ChainLogo.tsx @@ -0,0 +1,21 @@ +import type { FC } from 'react' +import { base } from 'viem/chains' +import { BaseLogo } from './BaseLogo.tsx' + +interface ChainLogoProps { + chainId?: number +} + +export const ChainLogo: FC = ({ chainId }) => { + return ( +
+ {chainId === base.id ? ( + + ) : ( + + + + )} +
+ ) +} diff --git a/src/components/rofl-paymaster/TokenLogo/RoseLogo.tsx b/src/components/rofl-paymaster/TokenLogo/RoseLogo.tsx new file mode 100644 index 00000000..0cab50a0 --- /dev/null +++ b/src/components/rofl-paymaster/TokenLogo/RoseLogo.tsx @@ -0,0 +1,12 @@ +import type { FC, SVGProps } from 'react' + +export const RoseLogo: FC> = props => ( + +) diff --git a/src/components/rofl-paymaster/TokenLogo/USDCLogo.tsx b/src/components/rofl-paymaster/TokenLogo/USDCLogo.tsx new file mode 100644 index 00000000..8f0d80b3 --- /dev/null +++ b/src/components/rofl-paymaster/TokenLogo/USDCLogo.tsx @@ -0,0 +1,24 @@ +import type { FC, SVGProps } from 'react' + +export const USDCLogo: FC> = props => ( + +) diff --git a/src/components/rofl-paymaster/TokenLogo/USDTLogo.tsx b/src/components/rofl-paymaster/TokenLogo/USDTLogo.tsx new file mode 100644 index 00000000..e65bc8da --- /dev/null +++ b/src/components/rofl-paymaster/TokenLogo/USDTLogo.tsx @@ -0,0 +1,21 @@ +import type { FC, SVGProps } from 'react' + +export const USDTLogo: FC> = props => ( + +) diff --git a/src/components/rofl-paymaster/TokenLogo/index.tsx b/src/components/rofl-paymaster/TokenLogo/index.tsx index 6c2d7f67..79a2bea5 100644 --- a/src/components/rofl-paymaster/TokenLogo/index.tsx +++ b/src/components/rofl-paymaster/TokenLogo/index.tsx @@ -1,34 +1,21 @@ import type { FC } from 'react' -import { ChainNativeCurrency } from '../../../types/rofl-paymaster.ts' -import { ROFL_PAYMASTER_ENABLED_CHAINS } from '../../../constants/rofl-paymaster-config.ts' +import { USDCLogo } from './USDCLogo.tsx' +import { USDTLogo } from './USDTLogo.tsx' +import { RoseLogo } from './RoseLogo.tsx' interface ChainTokenLogoProps { - chainId?: number - token?: ChainNativeCurrency + tokenSymbol?: string } -// TODO: use the token logo once available -export const TokenLogo: FC = ({ chainId, token }) => { - let displayToken: (ChainNativeCurrency & { logoURI?: string }) | undefined = - token || - (chainId ? ROFL_PAYMASTER_ENABLED_CHAINS?.find(t => t.id === chainId)?.nativeCurrency : undefined) - - displayToken = { - name: '', - symbol: '', - decimals: 18, - ...(displayToken || {}), - logoURI: '', - } - +export const TokenLogo: FC = ({ tokenSymbol }) => { return ( -
- {displayToken?.logoURI ? ( - {displayToken.symbol} +
+ {tokenSymbol === 'USDC' ? ( + + ) : tokenSymbol === 'USDT' || tokenSymbol === 'Tether USD' ? ( + + ) : tokenSymbol === 'ROSE' ? ( + ) : ( diff --git a/src/components/rofl-paymaster/TopUp/index.tsx b/src/components/rofl-paymaster/TopUp/index.tsx index 1cd70b5d..f1e99f81 100644 --- a/src/components/rofl-paymaster/TopUp/index.tsx +++ b/src/components/rofl-paymaster/TopUp/index.tsx @@ -1,4 +1,4 @@ -import React, { type FC, type ReactNode, useEffect, useMemo, useRef, useState } from 'react' +import React, { type FC, type ReactNode, useEffect, useMemo, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' @@ -16,12 +16,10 @@ import type { GetBalanceReturnType } from 'wagmi/actions' import { useAccount } from 'wagmi' import { NumberUtils } from '../../../utils/number.utils' import { FormatUtils } from '../../../utils/format.utils' -import { checkAndSetErc20Allowance, getErc20Balance, switchToChain } from '../../../contracts/erc-20' +import { getErc20Balance } from '../../../contracts/erc-20' import { FaucetInfo } from '../FaucetInfo' import { ProgressStep, TopUpProgressDialog } from '../TopUpProgressDialog' import { useNetwork } from '../../../hooks/useNetwork' -import { sapphireTestnet } from 'viem/chains' -import { useChainModal } from '@rainbow-me/rainbowkit' import { Chain } from 'viem' import classes from './index.module.css' import { @@ -29,15 +27,14 @@ import { ROFL_PAYMASTER_DESTINATION_CHAIN_TOKEN, ROFL_PAYMASTER_ENABLED_CHAINS, ROFL_PAYMASTER_EXPECTED_TIME, + ROFL_PAYMASTER_MIN_USD_VALUE, ROFL_PAYMASTER_TOKEN_CONFIG, } from '../../../constants/rofl-paymaster-config' import { RoflPaymasterContextProvider } from '../../../contexts/RoflPaymaster/Provider' -import { useRoflPaymasterContext } from '../../../contexts/RoflPaymaster/hooks' import { ChainNativeCurrency } from '../../../types/rofl-paymaster' import { TransactionSummary } from '../TransactionSummary' -import { TopUpInitializationFailed } from '../TopUpInitializationFailed' - -const { VITE_FEATURE_FLAG_PAYMASTER } = import.meta.env +import { usePaymaster } from '../../../hooks/usePaymaster' +import { ChainLogo } from '../TokenLogo/ChainLogo' const bridgeFormSchema = z.object({ sourceChain: z @@ -61,6 +58,9 @@ const bridgeFormSchema = z.object({ .min(1, 'Amount is required') .refine(val => NumberUtils.isValidAmount(val), { message: 'Amount must be a valid positive number', + }) + .refine(val => Number(val) >= ROFL_PAYMASTER_MIN_USD_VALUE, { + message: `Minimum amount is $${ROFL_PAYMASTER_MIN_USD_VALUE}`, }), destinationChain: z .object({ @@ -98,25 +98,9 @@ interface TopUpProps { const TopUpCmp: FC = ({ children, minAmount, onValidChange, onTopUpSuccess, onTopUpError }) => { const { address } = useAccount() - const { openChainModal } = useChainModal() - const { getQuote, createDeposit, pollPayment } = useRoflPaymasterContext() const [selectedChainTokens, setSelectedChainTokens] = useState(null) - const [quote, setQuote] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [topUpError, setTopUpError] = useState('') - - const [currentStep, setCurrentStep] = useState(null) - const [stepStatuses, setStepStatuses] = useState<{ - [key: number]: 'pending' | 'processing' | 'completed' | 'error' - }>({}) - - const updateStepStatus = (stepId: number, status: 'pending' | 'processing' | 'completed' | 'error') => { - setStepStatuses(prev => ({ - ...prev, - [stepId]: status, - })) - } + const [isTokenDropdownOpen, setIsTokenDropdownOpen] = useState(false) useEffect(() => { document.body.classList.add('topUp') @@ -144,6 +128,78 @@ const TopUpCmp: FC = ({ children, minAmount, onValidChange, onTopUpS }, }) + // Auto-select source chain if only one option is available + useEffect(() => { + const availableChains = ROFL_PAYMASTER_ENABLED_CHAINS?.filter( + chain => chain.id !== (ROFL_PAYMASTER_DESTINATION_CHAIN.id as number), + ) + + if (availableChains?.length === 1 && !form.getValues('sourceChain.id')) { + const chain = availableChains[0] + form.setValue('sourceChain', { id: chain.id, name: chain.name }) + } + }, [form]) + + const sourceTokenSymbol = form.watch('sourceToken.symbol') + const sourceTokenAddress = form.watch('sourceToken.contractAddress') + const selectedSourceToken = useMemo(() => { + return selectedChainTokens?.find( + t => t.symbol === sourceTokenSymbol && t.contractAddress === sourceTokenAddress, + ) + }, [selectedChainTokens, sourceTokenSymbol, sourceTokenAddress]) + + const paymasterTokenConfig = useMemo(() => { + if (!selectedSourceToken) return null + return { + contractAddress: selectedSourceToken.contractAddress, + symbol: selectedSourceToken.symbol, + decimals: selectedSourceToken.decimals, + name: selectedSourceToken.name, + } + }, [selectedSourceToken]) + + const progressSteps: ProgressStep[] = [ + { + id: 1, + label: 'Validating chain connection', + description: 'Ensuring wallet is connected to correct blockchain network', + }, + { + id: 2, + label: 'Approving token spend', + description: 'Granting permission to smart contract for token transfer', + }, + { + id: 3, + label: 'Executing deposit transaction', + description: 'Initiating cross-chain token transfer', + }, + { + id: 4, + label: 'Confirming completion', + description: 'Monitoring transaction until tokens arrive on destination chain', + expectedTimeInSeconds: ROFL_PAYMASTER_EXPECTED_TIME, + }, + { + id: 5, + label: 'Validating chain connection', + description: 'Ensuring wallet is connected to correct blockchain network', + }, + ] + + const { + getQuote, + startTopUp, + currentStep, + stepStatuses, + isLoading, + error: paymasterError, + quote, + } = usePaymaster(paymasterTokenConfig, progressSteps, []) + + const currentStepId = currentStep?.id ?? null + const topUpError = paymasterError + const { watch, setValue, @@ -186,11 +242,8 @@ const TopUpCmp: FC = ({ children, minAmount, onValidChange, onTopUpS // Custom validation for minimum amount useEffect(() => { - // TODO: Remove, temporary validation removal for testing - return - if (quote) { - if (NumberUtils.isLessThan(quote!.toString(), minAmount.toString())) { + if (NumberUtils.isLessThan(quote.toString(), minAmount.toString())) { setError('destinationToken', { type: 'manual', message: `Amount cannot be less than minimum (${NumberUtils.formatTokenAmountWithSymbol(minAmount.toString())})`, @@ -234,6 +287,14 @@ const TopUpCmp: FC = ({ children, minAmount, onValidChange, onTopUpS setSelectedChainTokens(tokensWithBalance) + // Auto-select token if only one exists + if (tokensWithBalance.length === 1) { + setValue('sourceToken', { + symbol: tokensWithBalance[0].symbol, + contractAddress: tokensWithBalance[0].contractAddress, + }) + } + for (let i = 0; i < tokensWithBalance.length; i++) { const token = tokensWithBalance[i] try { @@ -258,14 +319,11 @@ const TopUpCmp: FC = ({ children, minAmount, onValidChange, onTopUpS } getTokensByChainId(sourceChain.id) - }, [watchedValues.sourceChain, setValue, address]) + }, [watchedValues.sourceChain.id, setValue, address, watchedValues.sourceChain]) useEffect(() => { const { sourceChain, sourceToken, amount, destinationChain, destinationToken } = watchedValues - setQuote(null) - setTopUpError('') - if ( !sourceChain?.id || !sourceToken?.symbol || @@ -281,7 +339,6 @@ const TopUpCmp: FC = ({ children, minAmount, onValidChange, onTopUpS } const fetchQuote = async () => { - setIsLoading(true) try { const selectedSourceToken = selectedChainTokens?.find(token => token.symbol === sourceToken.symbol) @@ -292,18 +349,10 @@ const TopUpCmp: FC = ({ children, minAmount, onValidChange, onTopUpS const expandedAmount = BigInt(NumberUtils.expandAmount(amount, selectedSourceToken.decimals)) - const quoteResponse = await getQuote( - selectedSourceToken.contractAddress, - expandedAmount, - ROFL_PAYMASTER_DESTINATION_CHAIN, - ) - - setQuote(quoteResponse) + await getQuote({ amount: expandedAmount }) } catch (error) { - setQuote(null) - setTopUpError((error as Error).message) - } finally { - setIsLoading(false) + // Error handling is managed by hook state + console.error('Error fetching quote', error) } } @@ -311,11 +360,11 @@ const TopUpCmp: FC = ({ children, minAmount, onValidChange, onTopUpS return () => clearTimeout(timeoutId) // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - watchedValues.sourceChain.id, - watchedValues.sourceToken.symbol, + watchedValues.sourceChain?.id, + watchedValues.sourceToken?.symbol, watchedValues.amount, - watchedValues.destinationChain.id, - watchedValues.destinationToken.symbol, + watchedValues.destinationChain?.id, + watchedValues.destinationToken?.symbol, selectedChainTokens, getQuote, ]) @@ -326,14 +375,12 @@ const TopUpCmp: FC = ({ children, minAmount, onValidChange, onTopUpS setSelectedChainTokens(null) setValue('sourceToken', { symbol: '', contractAddress: '' }) setValue('amount', '0') - setQuote(null) } const handleTokenSelect = (token: TokenWithBalance) => { setValue('sourceToken', { symbol: token.symbol, contractAddress: token.contractAddress }) setValue('amount', '0') - setQuote(null) } const handleAmountChange = (e: React.ChangeEvent) => { @@ -358,139 +405,19 @@ const TopUpCmp: FC = ({ children, minAmount, onValidChange, onTopUpS if (!quote) return - setIsLoading(true) - setTopUpError('') - const tokenWithBalance = selectedChainTokens!.find(({ contractAddress }) => contractAddress)! try { - // Step 1: Chain validation and switching - setCurrentStep(1) - updateStepStatus(1, 'processing') - const switchToSource = await switchToChain({ - targetChainId: formData.sourceChain.id!, - address, + await startTopUp({ + amount: BigInt(NumberUtils.expandAmount(formData.amount, tokenWithBalance.decimals)), + sourceChainId: formData.sourceChain.id!, }) - - if (!switchToSource.success) { - throw new Error(switchToSource.error) - } - updateStepStatus(1, 'completed') - - // Step 2: Token allowance approval - setCurrentStep(2) - updateStepStatus(2, 'processing') - - await checkAndSetErc20Allowance( - formData.sourceToken.contractAddress as `0x${string}`, - ROFL_PAYMASTER_TOKEN_CONFIG[formData.sourceChain.id!].paymasterContractAddress, - BigInt(NumberUtils.expandAmount(formData.amount, tokenWithBalance.decimals)), - address as `0x${string}`, - ) - updateStepStatus(2, 'completed') - - // Step 3: Execute cross-chain transaction - setCurrentStep(3) - updateStepStatus(3, 'processing') - - const { paymentId } = await createDeposit( - formData.sourceToken.contractAddress as `0x${string}`, - BigInt(NumberUtils.expandAmount(formData.amount, tokenWithBalance.decimals)), - address as `0x${string}`, - formData.sourceChain.id!, - ) - updateStepStatus(3, 'completed') - - // Step 4: Monitor payout completion - setCurrentStep(4) - updateStepStatus(4, 'processing') - - await pollPayment(paymentId, ROFL_PAYMASTER_DESTINATION_CHAIN) - updateStepStatus(4, 'completed') - - // Step 5: Switch back to app chain - setCurrentStep(5) - updateStepStatus(5, 'processing') - - const switchToAppChain = await switchToChain({ - targetChainId: sapphireTestnet.id, - address, - }) - - if (!switchToAppChain.success) { - console.error(switchToAppChain.error) - updateStepStatus(5, 'error') - openChainModal?.() - } else { - updateStepStatus(5, 'completed') - } - - setCurrentStep(null) onTopUpSuccess?.() } catch (error) { console.error('Topup transaction failed:', error) - if (currentStep) { - updateStepStatus(currentStep, 'error') - } - - setTopUpError((error as Error).message) onTopUpError?.(error as Error) - setCurrentStep(null) - } finally { - const switchToAppChainResult = await switchToChain({ - targetChainId: sapphireTestnet.id, - address, - }) - - if (!switchToAppChainResult.success) { - console.error(switchToAppChainResult.error) - openChainModal?.() - } - - setIsLoading(false) } } - const lastValidProgressStepsRef = useRef(null) - - const progressSteps = useMemo(() => { - if (!quote) { - return lastValidProgressStepsRef.current || [] - } - - const steps = [ - { - id: 1, - label: 'Validating chain connection', - description: 'Ensuring wallet is connected to correct blockchain network', - }, - { - id: 2, - label: 'Approving token spend', - description: 'Granting permission to smart contract for token transfer', - }, - { - id: 3, - label: 'Executing bridge transaction', - description: 'Initiating cross-chain token transfer', - }, - { - id: 4, - label: 'Confirming completion', - description: 'Monitoring transaction until tokens arrive on destination chain', - expectedTimeInSeconds: ROFL_PAYMASTER_EXPECTED_TIME, - }, - { - id: 5, - label: 'Validating chain connection', - description: 'Ensuring wallet is connected to correct blockchain network', - }, - ] - - lastValidProgressStepsRef.current = steps - - return steps - }, [quote]) - return (
@@ -503,7 +430,7 @@ const TopUpCmp: FC = ({ children, minAmount, onValidChange, onTopUpS - + {ROFL_PAYMASTER_ENABLED_CHAINS?.filter( chain => chain.id !== (ROFL_PAYMASTER_DESTINATION_CHAIN.id as number), ).map(chain => ( @@ -520,7 +451,7 @@ const TopUpCmp: FC = ({ children, minAmount, onValidChange, onTopUpS onClick={() => handleChainSelect(chain)} className="flex items-center gap-2 cursor-pointer" > - + {chain.name} ))} @@ -536,6 +467,16 @@ const TopUpCmp: FC = ({ children, minAmount, onValidChange, onTopUpS onChange={handleAmountChange} disabled={!watchedValues.sourceToken?.symbol} /> + {!watchedValues.sourceToken?.symbol && ( +
{ + if (watchedValues.sourceChain?.id) { + setIsTokenDropdownOpen(true) + } + }} + /> + )}
- + - + {selectedChainTokens?.map(token => ( = ({ children, minAmount, onValidChange, onTopUpS className="flex items-center gap-2 cursor-pointer" >
- +
{token.symbol} - {FormatUtils.formatBalance(token.balance, token.isLoadingBalance)} + {token.isLoadingBalance + ? 'Loading...' + : token.balance + ? Number(token.balance.formatted).toFixed(2) + : '-/-'}
@@ -613,12 +562,12 @@ const TopUpCmp: FC = ({ children, minAmount, onValidChange, onTopUpS
@@ -629,7 +578,7 @@ const TopUpCmp: FC = ({ children, minAmount, onValidChange, onTopUpS
{topUpError && ( -

+

{topUpError.length > 150 ? `${topUpError.slice(0, 150)}...` : topUpError}

)} @@ -648,14 +597,11 @@ const TopUpCmp: FC = ({ children, minAmount, onValidChange, onTopUpS
{ - setCurrentStep(null) - setStepStatuses({}) - }} + onClose={() => {}} />
) @@ -664,17 +610,7 @@ const TopUpCmp: FC = ({ children, minAmount, onValidChange, onTopUpS export const TopUp: FC = props => { const network = useNetwork() - if (network === 'mainnet') { - return ( - <> - - {/* Fallback navigation */} - {props.children?.({ isValid: false })} - - ) - } - - if (network === 'testnet' && !VITE_FEATURE_FLAG_PAYMASTER) { + if (network === 'testnet') { return } diff --git a/src/components/rofl-paymaster/TopUpInitializationFailed/index.tsx b/src/components/rofl-paymaster/TopUpInitializationFailed/index.tsx deleted file mode 100644 index fe1ecc7c..00000000 --- a/src/components/rofl-paymaster/TopUpInitializationFailed/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { FC } from 'react' -import { Card } from '@oasisprotocol/ui-library/src/components/ui/card.tsx' -import { AlertTriangle, ExternalLink } from 'lucide-react' -import { ROSE_APP_URL } from '../../../constants/global.ts' - -export const TopUpInitializationFailed: FC = () => { - return ( - -
-
- -

Top-up Not Available

-
-

- The wallet top-up functionality is currently not available. You can still use the ROSE App, to help - you transfer $ROSE to your Sapphire account. -

- - - Open ROSE App - -
-
- ) -} diff --git a/src/components/rofl-paymaster/TopUpProgressDialog/index.tsx b/src/components/rofl-paymaster/TopUpProgressDialog/index.tsx index 8dbce5fb..1262784d 100644 --- a/src/components/rofl-paymaster/TopUpProgressDialog/index.tsx +++ b/src/components/rofl-paymaster/TopUpProgressDialog/index.tsx @@ -8,7 +8,8 @@ import { } from '@oasisprotocol/ui-library/src/components/ui/dialog' import { Button } from '@oasisprotocol/ui-library/src/components/ui/button' import { Spinner } from '../../Spinner' -import { useCountdownTimer } from './useCountdownTimer' +import { useCountdownTimer } from '../../../hooks/useCountdownTimer' +import { type PaymasterStepStatus } from '../../../hooks/usePaymaster' export interface ProgressStep { id: number @@ -20,9 +21,7 @@ export interface ProgressStep { interface TopUpProgressDialogProps { isOpen: boolean currentStep: number | null - stepStatuses: { - [key: number]: 'pending' | 'processing' | 'completed' | 'error' - } + stepStatuses: Partial> progressSteps: ProgressStep[] onClose: () => void } diff --git a/src/constants/global.ts b/src/constants/global.ts index 76e26ce6..7b80a52d 100644 --- a/src/constants/global.ts +++ b/src/constants/global.ts @@ -1,2 +1 @@ export const FAUCET_URL = 'https://faucet.testnet.oasis.io/' -export const ROSE_APP_URL = 'https://rose.oasis.io/' diff --git a/src/constants/rofl-paymaster-config.ts b/src/constants/rofl-paymaster-config.ts index d05f01d0..bcf047ed 100644 --- a/src/constants/rofl-paymaster-config.ts +++ b/src/constants/rofl-paymaster-config.ts @@ -1,34 +1,39 @@ -import { sapphire, sapphireTestnet, sepolia } from 'viem/chains' +import { sapphire, base, sapphireTestnet } from 'wagmi/chains' import { Address } from 'viem' -export const ROFL_PAYMASTER_ENABLED_CHAINS = [sepolia] +export const ROFL_PAYMASTER_ENABLED_CHAINS = [base] export const ROFL_PAYMASTER_ENABLED_CHAINS_IDS = ROFL_PAYMASTER_ENABLED_CHAINS.map(chain => chain.id.toString(), ) export const ROFL_PAYMASTER_NATIVE_TOKEN_ADDRESS = '0xNATIVE' -export const ROFL_PAYMASTER_EXPECTED_TIME = 30 // 30 seconds +export const ROFL_PAYMASTER_EXPECTED_TIME = 60 // 60 seconds export const ROFL_PAYMASTER_DEPOSIT_GAS_LIMIT = 500_000n -export const ROFL_PAYMASTER_DESTINATION_CHAIN = sapphireTestnet -export const ROFL_PAYMASTER_DESTINATION_CHAIN_TOKEN = sapphireTestnet.nativeCurrency +export const ROFL_PAYMASTER_DESTINATION_CHAIN = sapphire +export const ROFL_PAYMASTER_DESTINATION_CHAIN_TOKEN = sapphire.nativeCurrency -type RoflPaymasterTokenConfig = { - [chainId: string]: { - paymasterContractAddress: Address - TOKENS: { - contractAddress: Address - symbol: string - decimals: number - name: string - }[] - } +export type RoflPaymasterTokenConfig = { + contractAddress: Address + symbol: string + decimals: number + name: string +} +export type RoflPaymasterChainConfig = { + paymasterContractAddress: Address + TOKENS: RoflPaymasterTokenConfig[] } -export const ROFL_PAYMASTER_TOKEN_CONFIG: RoflPaymasterTokenConfig = { - [sepolia.id]: { - paymasterContractAddress: '0x7304cAc43536CAE229c0D55129B867CCF43F83F4', +export const ROFL_PAYMASTER_TOKEN_CONFIG: Record = { + [base.id]: { + paymasterContractAddress: '0x7D3B4dd07bd523E519e0A91afD8e3B325586fb5b', TOKENS: [ { - contractAddress: '0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0', + contractAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + symbol: 'USDC', + decimals: 6, + name: 'USDC', + }, + { + contractAddress: '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2', symbol: 'USDT', decimals: 6, name: 'Tether USD', @@ -38,7 +43,8 @@ export const ROFL_PAYMASTER_TOKEN_CONFIG: RoflPaymasterTokenConfig = { } export const ROFL_PAYMASTER_SAPPHIRE_CONTRACT_CONFIG = { - // TODO: replace with actual address once deployed on mainnet - [sapphire.id]: '0x0000000000000000000000000000000000000000' as Address, + [sapphire.id]: '0x6997953a4458F019506370110e84eefF52d375ad' as Address, [sapphireTestnet.id]: '0xa26733606bf8e0bD8d77Bddb707F05d7708EfBf7' as Address, } + +export const ROFL_PAYMASTER_MIN_USD_VALUE = 1 diff --git a/src/constants/wagmi-config.ts b/src/constants/wagmi-config.ts index ce4e9f38..834813b7 100644 --- a/src/constants/wagmi-config.ts +++ b/src/constants/wagmi-config.ts @@ -2,7 +2,7 @@ import { getDefaultConfig } from '@rainbow-me/rainbowkit' import { sapphire, sapphireTestnet } from 'viem/chains' import { ROFL_PAYMASTER_ENABLED_CHAINS } from './rofl-paymaster-config.ts' -const { VITE_WALLET_CONNECT_PROJECT_ID, VITE_FEATURE_FLAG_PAYMASTER } = import.meta.env +const { VITE_WALLET_CONNECT_PROJECT_ID } = import.meta.env declare module 'wagmi' { interface Register { @@ -13,7 +13,7 @@ declare module 'wagmi' { export const wagmiConfig: ReturnType = getDefaultConfig({ appName: 'ROFL App', projectId: VITE_WALLET_CONNECT_PROJECT_ID, - chains: [sapphire, sapphireTestnet, ...(VITE_FEATURE_FLAG_PAYMASTER ? ROFL_PAYMASTER_ENABLED_CHAINS : [])], + chains: [sapphire, sapphireTestnet, ...ROFL_PAYMASTER_ENABLED_CHAINS], ssr: false, batch: { multicall: false, diff --git a/src/contracts/erc-20.ts b/src/contracts/erc-20.ts index 0d034d4e..5e8389a4 100644 --- a/src/contracts/erc-20.ts +++ b/src/contracts/erc-20.ts @@ -1,4 +1,4 @@ -import { Address, maxUint256 } from 'viem' +import { Address } from 'viem' import { readContract, waitForTransactionReceipt, @@ -43,7 +43,7 @@ export const checkAndSetErc20Allowance = async ( approvalAddress: Address, amount: bigint, userAddress: Address, - allowanceAmount = maxUint256, + allowanceAmount = amount, ): Promise => { // Transactions with the native token don't need approval if (tokenAddress.toLowerCase() === ROFL_PAYMASTER_NATIVE_TOKEN_ADDRESS) { @@ -96,6 +96,16 @@ export const switchToChain = async ({ throw new Error('Wallet not connected') } + const waitUntilOnChain = async (expectedChainId: number, timeoutMs: number, pollIntervalMs = 250) => { + const t0 = Date.now() + while (Date.now() - t0 < timeoutMs) { + const current = await getChainId(wagmiConfig) + if (current === expectedChainId) return true + await new Promise(resolve => setTimeout(resolve, pollIntervalMs)) + } + return false + } + try { const actualCurrentChainId = await getChainId(wagmiConfig) @@ -109,8 +119,13 @@ export const switchToChain = async ({ await Promise.race([switchChain(wagmiConfig, { chainId: targetChainId }), timeoutPromise]) - await new Promise(resolve => setTimeout(resolve, 7000)) - return { success: true } + const settled = await waitUntilOnChain(targetChainId, 20_000) + if (settled) return { success: true } + + return { + success: false, + error: `Chain switch did not settle in time (expected ${targetChainId}).`, + } } catch (switchError) { if (switchError instanceof Error && switchError.message.includes('Unsupported Chain')) { console.warn("Got 'Unsupported Chain' error, likely succeeded, but throwing anyway.") diff --git a/src/components/rofl-paymaster/TopUpProgressDialog/useCountdownTimer.ts b/src/hooks/useCountdownTimer.ts similarity index 100% rename from src/components/rofl-paymaster/TopUpProgressDialog/useCountdownTimer.ts rename to src/hooks/useCountdownTimer.ts diff --git a/src/hooks/usePaymaster.ts b/src/hooks/usePaymaster.ts new file mode 100644 index 00000000..ec988b5b --- /dev/null +++ b/src/hooks/usePaymaster.ts @@ -0,0 +1,261 @@ +import { useCallback, useState } from 'react' +import { useAccount, useBalance, type BaseError } from 'wagmi' +import { checkAndSetErc20Allowance, switchToChain } from '../contracts/erc-20' +import { Address } from 'viem' +import { + ROFL_PAYMASTER_DESTINATION_CHAIN, + ROFL_PAYMASTER_TOKEN_CONFIG, +} from '../constants/rofl-paymaster-config.ts' +import { useRoflPaymasterContext } from '../contexts/RoflPaymaster/hooks' + +export type PaymasterStepStatus = 'pending' | 'processing' | 'completed' | 'error' + +export type ProgressStep = { + id: number + label: string + description: string + expectedTimeInSeconds?: number +} + +export type ProgressStepWithAction = ProgressStep & { + action: () => Promise +} + +export type StartTopUpParams = { + amount: bigint + sourceChainId: number +} + +export type GetQuoteParams = { + amount: bigint +} + +interface RoflPaymasterTokenConfig { + contractAddress: Address + symbol: string + decimals: number + name: string +} + +export type UsePaymasterTopUpFlowReturn = { + isLoading: boolean + initialLoading: boolean + error: string + quote: bigint | null + + currentStep: ProgressStep | ProgressStepWithAction | null + stepStatuses: Partial> + + getQuote: (p: GetQuoteParams) => Promise + startTopUp: (p: StartTopUpParams) => Promise<{ paymentId: string | null }> + reset: () => void +} + +export function usePaymaster( + targetToken: RoflPaymasterTokenConfig | null, + progressSteps: ProgressStep[] = [], + additionalSteps: ProgressStepWithAction[] = [], +): UsePaymasterTopUpFlowReturn { + const { address } = useAccount() + const { refetch: refetchSapphireNativeBalance } = useBalance({ + address, + chainId: ROFL_PAYMASTER_DESTINATION_CHAIN.id, + query: { enabled: !!address }, + }) + + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState('') + const [quote, setQuote] = useState(null) + + const [currentStep, setCurrentStep] = useState(null) + const [stepStatuses, setStepStatuses] = useState>>({}) + + const updateStep = useCallback((step: number, status: PaymasterStepStatus) => { + setCurrentStep(step) + setStepStatuses(prev => ({ ...prev, [step]: status })) + }, []) + + const reset = useCallback(() => { + setIsLoading(false) + setError('') + setQuote(null) + setCurrentStep(null) + setStepStatuses({}) + }, []) + + const { getQuote: getQuoteCtx, createDeposit, pollPayment } = useRoflPaymasterContext() + + const getQuote = useCallback( + async ({ amount }: GetQuoteParams) => { + setError('') + setQuote(null) + + if (!address) throw new Error('Wallet not connected') + + setIsLoading(true) + try { + const q = await getQuoteCtx(targetToken!.contractAddress, amount, ROFL_PAYMASTER_DESTINATION_CHAIN) + setQuote(q) + return q + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to fetch quote' + setError(msg) + throw e + } finally { + setIsLoading(false) + } + }, + [address, getQuoteCtx, targetToken], + ) + + const waitForSapphireNativeBalanceIncrease = useCallback( + async ({ + baseline, + timeoutMs, + intervalMs, + minIncrease, + }: { + baseline: bigint + timeoutMs: number + intervalMs: number + minIncrease: bigint + }) => { + const startedAt = Date.now() + while (Date.now() - startedAt < timeoutMs) { + const res = await refetchSapphireNativeBalance() + const current = res.data?.value + + if (typeof current === 'bigint' && current >= baseline + minIncrease) return current + + await new Promise(resolve => setTimeout(resolve, intervalMs)) + } + + throw new Error('Payment likely processed, but Sapphire native balance did not increase in time') + }, + [refetchSapphireNativeBalance], + ) + + const startTopUp = useCallback( + async ({ amount, sourceChainId }: StartTopUpParams) => { + setError('') + + if (!address) throw new Error('Wallet not connected') + if (!targetToken) throw new Error('Target token not selected') + + const sourceChainConfig = ROFL_PAYMASTER_TOKEN_CONFIG[sourceChainId] + + setIsLoading(true) + setCurrentStep(1) + try { + // Snapshot baseline Sapphire native balance + let baselineSapphireNative = 0n + try { + const res = await refetchSapphireNativeBalance() + baselineSapphireNative = res.data?.value ?? 0n + } catch (e) { + console.warn('Failed to fetch baseline balance', e) + } + + // Step 1: switch to source + updateStep(1, 'processing') + await switchToChain({ targetChainId: sourceChainId, address }) + updateStep(1, 'completed') + + // Step 2: allowance + updateStep(2, 'processing') + await checkAndSetErc20Allowance( + targetToken.contractAddress, + sourceChainConfig.paymasterContractAddress, + amount, + address, + ) + updateStep(2, 'completed') + + // Step 3: create a deposit + updateStep(3, 'processing') + const { paymentId } = await createDeposit(targetToken.contractAddress, amount, address, sourceChainId) + updateStep(3, 'completed') + + // Step 4: poll + updateStep(4, 'processing') + await pollPayment(paymentId!, ROFL_PAYMASTER_DESTINATION_CHAIN) + + try { + await waitForSapphireNativeBalanceIncrease({ + baseline: baselineSapphireNative, + timeoutMs: 3 * 60_000, + intervalMs: 4_000, + minIncrease: 1n, + }) + } catch (e) { + console.warn('Balance check failed or timed out', e) + } + + updateStep(4, 'completed') + + // Step 5: switch to destination + updateStep(5, 'processing') + await switchToChain({ + targetChainId: ROFL_PAYMASTER_DESTINATION_CHAIN.id, + address, + }) + updateStep(5, 'completed') + + // Additional steps + for (let i = 0; i < additionalSteps.length; i++) { + updateStep(i + 6, 'processing') + await additionalSteps[i].action() + updateStep(i + 6, 'completed') + } + + setCurrentStep(null) + return { paymentId } + } catch (e) { + const msg = e instanceof Error ? (e as BaseError).shortMessage || e.message : 'Top up failed' + setError(msg) + + if (currentStep) { + setStepStatuses(prev => ({ ...prev, [currentStep]: 'error' })) + } + + setCurrentStep(null) + throw e + } finally { + setIsLoading(false) + setCurrentStep(null) + + await switchToChain({ + targetChainId: ROFL_PAYMASTER_DESTINATION_CHAIN.id, + address, + }) + } + }, + [ + address, + createDeposit, + currentStep, + pollPayment, + updateStep, + targetToken, + additionalSteps, + refetchSapphireNativeBalance, + waitForSapphireNativeBalanceIncrease, + ], + ) + + return { + isLoading, + error, + quote, + currentStep: currentStep + ? currentStep <= progressSteps.length + ? progressSteps[currentStep - 1] + : additionalSteps[currentStep - 1 - progressSteps.length] + : null, + stepStatuses, + getQuote, + startTopUp, + reset, + initialLoading: false, + } +} diff --git a/src/pages/CreateApp/PaymentStep.tsx b/src/pages/CreateApp/PaymentStep.tsx index 1c58d454..e958175b 100644 --- a/src/pages/CreateApp/PaymentStep.tsx +++ b/src/pages/CreateApp/PaymentStep.tsx @@ -15,8 +15,6 @@ import { sapphire, sapphireTestnet } from 'viem/chains' import { useTicker } from '../../hooks/useTicker' import { useBlockNavigatingAway } from './useBlockNavigatingAway.ts' -const { VITE_FEATURE_FLAG_PAYMASTER } = import.meta.env - type PaymentStepProps = { handleNext: () => void handleBack: () => void @@ -113,7 +111,9 @@ export const PaymentStep: FC = ({ )} {!hasEnoughBalance && minAmount && (

- You need more ${chain.nativeCurrency.symbol} to complete this process. + You need more $ + {network === 'mainnet' ? sapphire.nativeCurrency.symbol : sapphireTestnet.nativeCurrency.symbol} to + complete this process.
Top up your wallet below.

@@ -140,14 +140,14 @@ export const PaymentStep: FC = ({ )} - {isTestnetBlocked && !VITE_FEATURE_FLAG_PAYMASTER && ( + {isTestnetBlocked && (
Functionality is currently blocked on the Oasis Sapphire Testnet. To build and deploy your application, please switch to Mainnet.
)} - {(hasEnoughBalance || (isTestnet && !VITE_FEATURE_FLAG_PAYMASTER)) && ( + {hasEnoughBalance && (
{ diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 6dc5e08d..5f81f581 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -9,7 +9,6 @@ interface ImportMetaEnv { VITE_WALLET_CONNECT_PROJECT_ID: string VITE_ROFL_APP_BACKEND: string VITE_FATHOM_SIDE_ID: string - VITE_FEATURE_FLAG_PAYMASTER: string } interface ImportMeta {