diff --git a/examples/react-app/src/App.tsx b/examples/react-app/src/App.tsx index bc1761a..6a4e279 100644 --- a/examples/react-app/src/App.tsx +++ b/examples/react-app/src/App.tsx @@ -1,11 +1,6 @@ import { useRoutes } from 'react-router-dom'; import { AleoWalletProvider } from '@provablehq/aleo-wallet-adaptor-react'; import { WalletModalProvider } from '@provablehq/aleo-wallet-adaptor-react-ui'; -import { PuzzleWalletAdapter } from '@provablehq/aleo-wallet-adaptor-puzzle'; -import { LeoWalletAdapter } from '@provablehq/aleo-wallet-adaptor-leo'; -import { ShieldWalletAdapter } from '@provablehq/aleo-wallet-adaptor-shield'; -import { FoxWalletAdapter } from '@provablehq/aleo-wallet-adaptor-fox'; -import { SoterWalletAdapter } from '@provablehq/aleo-wallet-adaptor-soter'; import { toast, Toaster } from 'sonner'; import { ThemeProvider } from 'next-themes'; import { useAtomValue } from 'jotai'; @@ -16,16 +11,11 @@ import { programsAtom, } from './lib/store/global'; import { routes } from './routes'; +import { ShieldPayAdapter } from './lib/shieldPayAdapter'; // Import wallet adapter CSS after our own styles import '@provablehq/aleo-wallet-adaptor-react-ui/dist/styles.css'; -const wallets = [ - new ShieldWalletAdapter(), - new PuzzleWalletAdapter(), - new LeoWalletAdapter(), - new FoxWalletAdapter(), - new SoterWalletAdapter(), -]; +const wallets = [new ShieldPayAdapter()]; function AppRoutes() { const element = useRoutes(routes); diff --git a/examples/react-app/src/components/functions/ShieldPay.tsx b/examples/react-app/src/components/functions/ShieldPay.tsx new file mode 100644 index 0000000..affa6a5 --- /dev/null +++ b/examples/react-app/src/components/functions/ShieldPay.tsx @@ -0,0 +1,946 @@ +import { useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Separator } from '@/components/ui/separator'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Copy, + CheckCircle, + Loader2, + AlertCircle, + Shield, + Wallet, + Zap, + Coins, + Database, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { useWallet } from '@provablehq/aleo-wallet-adaptor-react'; +import { useWalletModal } from '@provablehq/aleo-wallet-adaptor-react-ui'; +import { TransactionStatus } from '@provablehq/aleo-types'; +import { CodePanel } from '../CodePanel'; +import { codeExamples, PLACEHOLDERS } from '@/lib/codeExamples'; +import { + DerivedAddresses, + EVM_CHAINS, + EvmChain, + EvmTransactionParams, + isShieldPayAdapter, +} from '@/lib/shieldPayAdapter'; +import { parseInputs } from '@/lib/utils'; + +const EVM_TX_TYPES = ['legacy', 'eip1559', 'eip2930'] as const; + +function parseOptionalNumber(value: string): number | undefined { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const parsed = Number(trimmed); + return Number.isNaN(parsed) ? undefined : parsed; +} + +function parseOptionalGas(value: string): string | number | undefined { + const trimmed = value.trim(); + if (!trimmed) return undefined; + if (trimmed.startsWith('0x')) return trimmed; + const parsed = Number(trimmed); + return Number.isNaN(parsed) ? trimmed : parsed; +} + +function formatEvmTransactionForCode(tx: EvmTransactionParams): string { + const entries = Object.entries(tx).map(([key, value]) => { + const formatted = typeof value === 'string' ? `'${value.replace(/'/g, "\\'")}'` : String(value); + return ` ${key}: ${formatted},`; + }); + return `{\n${entries.join('\n')}\n}`; +} + +export function ShieldPay() { + const { connected, wallet, transactionStatus: getTransactionStatus } = useWallet(); + const { setVisible: openWalletModal } = useWalletModal(); + const [index, setIndex] = useState('0'); + const [derivedAddresses, setDerivedAddresses] = useState({}); + const [isDeriving, setIsDeriving] = useState(false); + const [deriveError, setDeriveError] = useState(''); + const [program, setProgram] = useState('hello_world.aleo'); + const [functionName, setFunctionName] = useState('main'); + const [inputs, setInputs] = useState('1u32\n1u32'); + const [fee, setFee] = useState('10000'); + const [isExecutingTransaction, setIsExecutingTransaction] = useState(false); + const [isPollingStatus, setIsPollingStatus] = useState(false); + const [transactionId, setTransactionId] = useState(null); + const [onchainTransactionId, setOnchainTransactionId] = useState(null); + const [transactionStatus, setTransactionStatus] = useState(null); + const [transactionError, setTransactionError] = useState(null); + const [executeError, setExecuteError] = useState(''); + const [evmChain, setEvmChain] = useState('ethereum-sepolia'); + const [evmTo, setEvmTo] = useState(''); + const [evmData, setEvmData] = useState(''); + const [evmValue, setEvmValue] = useState('0x0'); + const [evmType, setEvmType] = useState<(typeof EVM_TX_TYPES)[number]>('eip1559'); + const [evmGas, setEvmGas] = useState(''); + const [evmGasPrice, setEvmGasPrice] = useState(''); + const [evmMaxFeePerGas, setEvmMaxFeePerGas] = useState(''); + const [evmMaxPriorityFeePerGas, setEvmMaxPriorityFeePerGas] = useState(''); + const [evmNonce, setEvmNonce] = useState(''); + const [evmChainId, setEvmChainId] = useState(''); + const [isExecutingEvm, setIsExecutingEvm] = useState(false); + const [evmTransactionHash, setEvmTransactionHash] = useState(''); + const [evmExecuteError, setEvmExecuteError] = useState(''); + const [recordsProgram, setRecordsProgram] = useState('credits.aleo'); + const [recordsIncludePlaintext, setRecordsIncludePlaintext] = useState(false); + const [isFetchingRecords, setIsFetchingRecords] = useState(false); + const [derivedRecords, setDerivedRecords] = useState([]); + const [recordsError, setRecordsError] = useState(''); + const pollingIntervalRef = useRef(null); + + const shieldPayAdapter = + wallet?.adapter && isShieldPayAdapter(wallet.adapter) ? wallet.adapter : null; + + const isBusy = isDeriving || isExecutingTransaction || isExecutingEvm || isFetchingRecords; + + const buildEvmTransactionParams = (options?: { + requireTo?: boolean; + }): EvmTransactionParams | null => { + const requireTo = options?.requireTo ?? false; + + if (!evmTo.trim()) { + if (requireTo) toast.error('Recipient address (to) is required'); + return null; + } + + const transaction: EvmTransactionParams = { + to: evmTo.trim(), + }; + + if (evmData.trim()) transaction.data = evmData.trim(); + if (evmValue.trim()) transaction.value = evmValue.trim(); + if (evmType) transaction.type = evmType; + + const gas = parseOptionalGas(evmGas); + if (gas !== undefined) transaction.gas = gas; + if (evmGasPrice.trim()) transaction.gasPrice = evmGasPrice.trim(); + if (evmMaxFeePerGas.trim()) transaction.maxFeePerGas = evmMaxFeePerGas.trim(); + if (evmMaxPriorityFeePerGas.trim()) { + transaction.maxPriorityFeePerGas = evmMaxPriorityFeePerGas.trim(); + } + + const nonce = parseOptionalNumber(evmNonce); + if (nonce !== undefined) transaction.nonce = nonce; + + const chainId = parseOptionalNumber(evmChainId); + if (chainId !== undefined) transaction.chainId = chainId; + + return transaction; + }; + + useEffect(() => { + return () => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + } + }; + }, []); + + const parseIndex = (): number | null => { + const parsed = Number.parseInt(index, 10); + if (Number.isNaN(parsed) || parsed < 0) { + toast.error('Please enter a valid non-negative account index'); + return null; + } + return parsed; + }; + + const handleDeriveAddresses = async () => { + if (!connected) { + openWalletModal(true); + return; + } + if (!shieldPayAdapter) { + toast.error('Connect with Shield wallet to use Shield Pay'); + return; + } + + const accountIndex = parseIndex(); + if (accountIndex === null) return; + + setIsDeriving(true); + setDeriveError(''); + setDerivedAddresses({}); + + try { + const addresses = await shieldPayAdapter.deriveAddresses(accountIndex); + setDerivedAddresses(addresses); + toast.success('Addresses derived'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to derive addresses'; + setDeriveError(errorMessage); + toast.error('Failed to derive addresses'); + } finally { + setIsDeriving(false); + } + }; + + const pollTransactionStatus = async (tempTransactionId: string) => { + function clear() { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + } + try { + const statusResponse = await getTransactionStatus(tempTransactionId); + setTransactionStatus(statusResponse.status); + if (statusResponse.transactionId) { + setOnchainTransactionId(statusResponse.transactionId); + } + + if (statusResponse.status.toLowerCase() === TransactionStatus.ACCEPTED.toLowerCase()) { + setIsPollingStatus(false); + clear(); + toast.success('Transaction ' + statusResponse.status); + } else if ( + statusResponse.status.toLowerCase() === TransactionStatus.FAILED.toLowerCase() || + statusResponse.status.toLowerCase() === TransactionStatus.REJECTED.toLowerCase() + ) { + setIsPollingStatus(false); + if (statusResponse.error) { + setTransactionError(statusResponse.error); + } + clear(); + toast.error('Transaction ' + statusResponse.status); + } + } catch (error) { + console.error('Error polling transaction status:', error); + toast.error('Error polling transaction status'); + setTransactionError('Error polling transaction status'); + setIsPollingStatus(false); + setTransactionStatus(TransactionStatus.FAILED); + clear(); + } + }; + + const handleExecuteTransactionOnDerivedAccount = async () => { + if (!connected) { + openWalletModal(true); + return; + } + if (!shieldPayAdapter) { + toast.error('Connect with Shield wallet to use Shield Pay'); + return; + } + + const accountIndex = parseIndex(); + if (accountIndex === null) return; + + if (!program.trim() || !functionName.trim() || !fee.trim()) { + toast.error('Please enter program, function, and fee'); + return; + } + + setIsExecutingTransaction(true); + setExecuteError(''); + setTransactionId(null); + setOnchainTransactionId(null); + setTransactionStatus(null); + setTransactionError(null); + + try { + const result = await shieldPayAdapter.executeTransactionOnDerivedAccount(accountIndex, { + program: program.trim(), + function: functionName.trim(), + inputs: parseInputs(inputs), + fee: Number(fee), + privateFee: false, + }); + + if (result?.transactionId) { + setTransactionId(result.transactionId); + toast.success('Transaction submitted'); + setIsPollingStatus(true); + + pollingIntervalRef.current = setInterval(() => { + pollTransactionStatus(result.transactionId); + }, 1000); + + pollTransactionStatus(result.transactionId); + } else { + toast.error('Failed to get transaction ID'); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to execute transaction'; + setExecuteError(errorMessage); + toast.error('Failed to execute transaction on derived account'); + } finally { + setIsExecutingTransaction(false); + } + }; + + const handleExecuteEvmTransaction = async () => { + if (!connected) { + openWalletModal(true); + return; + } + if (!shieldPayAdapter) { + toast.error('Connect with Shield wallet to use Shield Pay'); + return; + } + + const accountIndex = parseIndex(); + if (accountIndex === null) return; + + const transaction = buildEvmTransactionParams({ requireTo: true }); + if (!transaction) return; + + setIsExecutingEvm(true); + setEvmExecuteError(''); + setEvmTransactionHash(''); + + try { + const result = await shieldPayAdapter.executeEvmTransaction( + evmChain, + accountIndex, + transaction, + ); + setEvmTransactionHash(result.transactionHash); + toast.success('EVM transaction submitted'); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to execute EVM transaction'; + setEvmExecuteError(errorMessage); + toast.error('Failed to execute EVM transaction'); + } finally { + setIsExecutingEvm(false); + } + }; + + const handleRequestRecordsOnDerivedAccount = async () => { + if (!connected) { + openWalletModal(true); + return; + } + if (!shieldPayAdapter) { + toast.error('Connect with Shield wallet to use Shield Pay'); + return; + } + + const accountIndex = parseIndex(); + if (accountIndex === null) return; + + if (!recordsProgram.trim()) { + toast.error('Please enter a program ID'); + return; + } + + setIsFetchingRecords(true); + setRecordsError(''); + setDerivedRecords([]); + + try { + const records = await shieldPayAdapter.requestRecordsOnDerivedAccount( + accountIndex, + recordsProgram.trim(), + recordsIncludePlaintext, + ); + setDerivedRecords(records ?? []); + toast.success('Records fetched'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to fetch records'; + setRecordsError(errorMessage); + toast.error('Failed to fetch records on derived account'); + } finally { + setIsFetchingRecords(false); + } + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + toast.success('Copied to clipboard'); + }; + + const evmTransactionPreview = + buildEvmTransactionParams() ?? + ({ + to: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', + value: evmValue.trim() || '0x0', + type: evmType, + } satisfies EvmTransactionParams); + + const derivedAddressAlert = (label: string, address: string) => ( + + + +

{label}

+
+
+            {address}
+          
+ +
+
+
+ ); + + return ( +
+
+

Shield Pay

+

+ Derive addresses and execute Aleo or EVM transactions on derived accounts at a specific + index using the Shield wallet extension. +

+
+ +
+
+ + setIndex(e.target.value)} + className="transition-all duration-300" + /> +
+ + {connected && !shieldPayAdapter && ( + + + +

+ Shield Pay methods require the Shield wallet. Reconnect using Shield from the wallet + menu. +

+
+
+ )} + +
+ + + {deriveError && ( + + + +

Error deriving addresses

+

{deriveError}

+
+
+ )} + + {Object.entries(derivedAddresses).map(([chain, address]) => + address ? ( +
+ {derivedAddressAlert(`${chain} address`, address)} +
+ ) : null, + )} +
+ + + +
+
+

Execute on derived account

+

+ Runs{' '} + + executeTransactionOnDerivedAccount(index, transaction) + {' '} + on the account index above. +

+
+ +
+
+ + setProgram(e.target.value)} + /> +
+
+ + setFunctionName(e.target.value)} + /> +
+
+ + setFee(e.target.value)} + /> +
+
+ +