diff --git a/projects/TokenizeRWATemplate-frontend/src/components/TokenizeAsset.tsx b/projects/TokenizeRWATemplate-frontend/src/components/TokenizeAsset.tsx index 1604425..3eeed64 100644 --- a/projects/TokenizeRWATemplate-frontend/src/components/TokenizeAsset.tsx +++ b/projects/TokenizeRWATemplate-frontend/src/components/TokenizeAsset.tsx @@ -1,7 +1,7 @@ import { AlgorandClient } from '@algorandfoundation/algokit-utils' import { sha512_256 } from 'js-sha512' import { useSnackbar } from 'notistack' -import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react' +import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AiOutlineCloudUpload, AiOutlineInfoCircle, AiOutlineLoading3Quarters } from 'react-icons/ai' import { BsCoin } from 'react-icons/bs' import { useUnifiedWallet } from '../hooks/useUnifiedWallet' @@ -25,6 +25,14 @@ type CreatedAsset = { createdAt: string } +/** + * Tri-state for USDC opt-in status + * - 'loading': blockchain query in progress, UI should show spinner/loading + * - 'opted-in': confirmed on-chain that user has opted in + * - 'not-opted-in': confirmed on-chain that user has NOT opted in + */ +type UsdcStatus = 'loading' | 'opted-in' | 'not-opted-in' + const STORAGE_KEY = 'tokenize_assets' const LORA_BASE = 'https://lora.algokit.io/testnet' @@ -131,7 +139,23 @@ export default function TokenizeAsset() { const [transferAmount, setTransferAmount] = useState('1') const [transferLoading, setTransferLoading] = useState(false) - // ===== NFT mint state (new) ===== + // ===== USDC opt-in state ===== + // Uses tri-state ('loading' | 'opted-in' | 'not-opted-in') to prevent infinite re-renders + // Refs are used to track state without causing callback recreations + const [usdcStatus, setUsdcStatus] = useState('loading') + const [usdcBalance, setUsdcBalance] = useState(0n) + const [usdcOptInLoading, setUsdcOptInLoading] = useState(false) + + // Track if we've completed at least one successful blockchain check for this address + const [hasCheckedUsdcOnChain, setHasCheckedUsdcOnChain] = useState(false) + + // Refs to prevent circular dependencies and duplicate operations + const hasShownUsdcWarningRef = useRef(false) // Prevent duplicate snackbar warnings + const lastTransferModeRef = useRef('manual') // Track mode changes + const isCheckingUsdcRef = useRef(false) // Prevent duplicate status checks + const hasCheckedUsdcOnChainRef = useRef(false) // Track checked state (avoids stale closures) + + // ===== NFT mint state ===== const [selectedFile, setSelectedFile] = useState(null) const [previewUrl, setPreviewUrl] = useState('') const [nftLoading, setNftLoading] = useState(false) @@ -161,6 +185,266 @@ export default function TokenizeAsset() { const algodConfig = getAlgodConfigFromViteEnvironment() const algorand = useMemo(() => AlgorandClient.fromConfig({ algodConfig }), [algodConfig]) + // Derived booleans for convenience (only valid when hasCheckedUsdcOnChain is true) + const usdcOptedIn = usdcStatus === 'opted-in' + const usdcStatusLoading = usdcStatus === 'loading' + + /** + * Fetch USDC opt-in status from blockchain + * Uses asset-specific API for reliable opt-in detection + * Falls back to account information API if needed + */ + const checkUsdcOptInStatus = useCallback(async () => { + if (!activeAddress) { + setUsdcStatus('not-opted-in') + setUsdcBalance(0n) + setHasCheckedUsdcOnChain(false) + hasCheckedUsdcOnChainRef.current = false + isCheckingUsdcRef.current = false + return + } + + // Prevent duplicate concurrent calls + if (isCheckingUsdcRef.current) { + return + } + + isCheckingUsdcRef.current = true + + // Only set loading if we haven't checked yet (preserve existing status during refresh) + if (!hasCheckedUsdcOnChainRef.current) { + setUsdcStatus('loading') + } + + try { + // Method 1: Use asset-specific API (most reliable) + // Returns holding if opted in, throws if not opted in + let holding: any = null + let apiCallSucceeded = false + + try { + holding = await algorand.asset.getAccountInformation( + activeAddress, + BigInt(TESTNET_USDC_ASSET_ID), + ) + apiCallSucceeded = true + } catch (assetApiError: unknown) { + // API call failed - account is likely not opted in + const error = assetApiError as any + + // Check if it's a 404/not found error (definitely not opted in) + // vs a network error (should fall through to method 2) + const isNotFoundError = + error?.message?.includes('not found') || + error?.message?.includes('404') || + error?.status === 404 || + error?.response?.status === 404 + + if (isNotFoundError) { + setUsdcStatus('not-opted-in') + setUsdcBalance(0n) + setHasCheckedUsdcOnChain(true) + hasCheckedUsdcOnChainRef.current = true + return + } + // Non-404 error - fall through to method 2 for verification + } + + // Process successful API call result + if (apiCallSucceeded && holding) { + const holdingAny = holding as any + const amount = holdingAny?.amount ?? holdingAny?.balance ?? 0 + const balance = typeof amount === 'bigint' ? amount : BigInt(amount ?? 0) + + setUsdcStatus('opted-in') + setUsdcBalance(balance) + setHasCheckedUsdcOnChain(true) + hasCheckedUsdcOnChainRef.current = true + return + } + + // Method 2: Fallback to account information API + // Used when method 1 has non-404 errors or for verification + const info = await algorand.client.algod.accountInformation(activeAddress).do() + const assets: Array<{ ['asset-id']: number; amount?: number }> = info?.assets ?? [] + + const usdcHolding = assets.find((a) => a['asset-id'] === TESTNET_USDC_ASSET_ID) + + if (usdcHolding) { + const balance = BigInt(usdcHolding.amount ?? 0) + setUsdcStatus('opted-in') + setUsdcBalance(balance) + } else { + setUsdcStatus('not-opted-in') + setUsdcBalance(0n) + } + + // Mark that we've successfully completed a blockchain check + setHasCheckedUsdcOnChain(true) + hasCheckedUsdcOnChainRef.current = true + } catch (e) { + console.error('Failed to check USDC opt-in', e) + // On error, set to not-opted-in but don't mark as checked + // This allows retry on next render cycle + setUsdcStatus('not-opted-in') + setUsdcBalance(0n) + setHasCheckedUsdcOnChain(false) + hasCheckedUsdcOnChainRef.current = false + } finally { + isCheckingUsdcRef.current = false + } + // Note: hasCheckedUsdcOnChain is read from closure, not needed in deps + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeAddress, algorand]) + + // Effect: Check USDC status when address changes or on mount + // Small delay allows wallet state to stabilize after reconnect + useEffect(() => { + // Reset state when address changes + setHasCheckedUsdcOnChain(false) + hasCheckedUsdcOnChainRef.current = false + hasShownUsdcWarningRef.current = false + isCheckingUsdcRef.current = false + + if (!activeAddress) { + setUsdcStatus('not-opted-in') + setUsdcBalance(0n) + return + } + + // Set loading immediately, then check after delay + setUsdcStatus('loading') + + const timeoutId = setTimeout(() => { + checkUsdcOptInStatus() + }, 500) + + return () => clearTimeout(timeoutId) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeAddress]) + + // Effect: Handle transfer mode changes and show appropriate warnings + // Only shows warnings after blockchain state is confirmed (not during loading) + useEffect(() => { + const prevMode = lastTransferModeRef.current + const modeChanged = prevMode !== transferMode + lastTransferModeRef.current = transferMode + + // Set transfer asset ID based on mode + if (transferMode === 'algo') { + setTransferAssetId('ALGO') + } else if (transferMode === 'usdc') { + setTransferAssetId(String(TESTNET_USDC_ASSET_ID)) + + // Show warnings only when: + // 1. Actually switching TO usdc mode (not just re-render) + // 2. Blockchain check is complete (status confirmed) + // 3. Warning hasn't been shown already + if ( + modeChanged && + hasCheckedUsdcOnChain && + !hasShownUsdcWarningRef.current && + usdcStatus === 'not-opted-in' + ) { + enqueueSnackbar('You are not opted in to USDC yet. Please opt in before receiving or sending USDC.', { + variant: 'info', + }) + hasShownUsdcWarningRef.current = true + } else if ( + modeChanged && + hasCheckedUsdcOnChain && + !hasShownUsdcWarningRef.current && + usdcStatus === 'opted-in' && + usdcBalance === 0n + ) { + enqueueSnackbar('Heads up: you have 0 USDC to send.', { variant: 'info' }) + hasShownUsdcWarningRef.current = true + } + } else { + // Manual mode - reset asset ID if it was set to ALGO or USDC + if (transferAssetId === 'ALGO' || transferAssetId === String(TESTNET_USDC_ASSET_ID)) { + setTransferAssetId('') + } + // Prefill with latest created asset if available + if (!transferAssetId && createdAssets.length > 0) { + setTransferAssetId(String(createdAssets[0].assetId)) + } + } + + // Reset warning flag when leaving USDC mode + if (prevMode === 'usdc' && transferMode !== 'usdc') { + hasShownUsdcWarningRef.current = false + } + // Note: We intentionally don't include usdcStatus/usdcBalance in deps to avoid re-runs on status changes + // We only want this to run when transferMode or hasCheckedUsdcOnChain changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [transferMode, hasCheckedUsdcOnChain, enqueueSnackbar]) + + /** + * Opt-in to TestNet USDC + * Opt-in is an asset transfer of 0 USDC to self + */ + const handleOptInUsdc = async () => { + if (!signer || !activeAddress) { + enqueueSnackbar('Please connect a wallet or continue with Google first.', { variant: 'warning' }) + return + } + + // Prevent duplicate transactions if already opted in + if (usdcOptedIn) { + enqueueSnackbar('You are already opted in to USDC ✅', { variant: 'info' }) + return + } + + try { + setUsdcOptInLoading(true) + enqueueSnackbar('Opting into USDC...', { variant: 'info' }) + + // Opt-in = asset transfer of 0 to self + const result = await algorand.send.assetTransfer({ + sender: activeAddress, + signer, + assetId: TESTNET_USDC_ASSET_ID, + receiver: activeAddress, + amount: 0n, + }) + + const txId = (result as { txId?: string }).txId + + // Optimistically update status immediately after successful transaction + setUsdcStatus('opted-in') + setUsdcBalance(0n) + setHasCheckedUsdcOnChain(true) + hasCheckedUsdcOnChainRef.current = true + hasShownUsdcWarningRef.current = true // Prevent warning since we just opted in + + enqueueSnackbar('✅ USDC opted in!', { + variant: 'success', + action: () => + txId ? ( + + View Tx on Lora ↗ + + ) : null, + }) + + // Verify with blockchain after delay to confirm opt-in + setTimeout(() => { + checkUsdcOptInStatus() + }, 2000) + } catch (e) { + console.error('USDC opt-in failed', e) + enqueueSnackbar('USDC opt-in failed.', { variant: 'error' }) + } finally { + setUsdcOptInLoading(false) + } + } + useEffect(() => { setCreatedAssets(loadAssets()) }, []) @@ -182,25 +466,6 @@ export default function TokenizeAsset() { } }, [createdAssets, transferAssetId, transferMode]) - // When switching transfer mode, set the asset id display/value accordingly - useEffect(() => { - if (transferMode === 'algo') { - setTransferAssetId('ALGO') - } else if (transferMode === 'usdc') { - setTransferAssetId(String(TESTNET_USDC_ASSET_ID)) - } else { - // manual: keep whatever the user had, but if it was ALGO then clear - if (transferAssetId === 'ALGO' || transferAssetId === String(TESTNET_USDC_ASSET_ID)) { - setTransferAssetId('') - } - // If we have history, prefill from latest - if (!transferAssetId && createdAssets.length > 0) { - setTransferAssetId(String(createdAssets[0].assetId)) - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [transferMode]) - const resetDefaults = () => { setAssetName('Tokenized Coffee Membership') setUnitName('COFFEE') @@ -339,7 +604,8 @@ export default function TokenizeAsset() { } /** - * Transfer (Manual ASA / USDC ASA / ALGO payment) + * Transfer assets (Manual ASA / USDC ASA / ALGO payment) + * Handles validation, amount conversion, and transaction submission */ const handleTransferAsset = async () => { if (!signer || !activeAddress) { @@ -357,7 +623,7 @@ export default function TokenizeAsset() { return } - // Manual ASA validates Asset ID + whole-number amount + // Manual ASA: validate Asset ID and whole-number amount if (transferMode === 'manual') { if (!transferAssetId || !isWholeNumber(transferAssetId)) { enqueueSnackbar('Please enter a valid Asset ID (number).', { variant: 'warning' }) @@ -369,7 +635,7 @@ export default function TokenizeAsset() { } } - // USDC + ALGO allow decimals up to 6 + // USDC + ALGO: allow decimals up to 6 places if (transferMode === 'algo' || transferMode === 'usdc') { if (!/^\d+(\.\d+)?$/.test(transferAmount.trim())) { enqueueSnackbar('Amount must be a valid number (decimals allowed).', { variant: 'warning' }) @@ -377,6 +643,12 @@ export default function TokenizeAsset() { } } + // USDC: block transfer if not opted in (only if status is confirmed, not during loading) + if (transferMode === 'usdc' && hasCheckedUsdcOnChain && !usdcOptedIn) { + enqueueSnackbar('You must opt-in to USDC before you can send/receive it.', { variant: 'warning' }) + return + } + try { setTransferLoading(true) @@ -409,10 +681,25 @@ export default function TokenizeAsset() { ) : null, }) } else if (transferMode === 'usdc') { - enqueueSnackbar('Sending USDC...', { variant: 'info' }) + // Double-check opt-in status (in case it changed) + if (hasCheckedUsdcOnChain && !usdcOptedIn) { + enqueueSnackbar('You are not opted in to USDC yet. Please opt in first.', { variant: 'warning' }) + return + } + if (usdcBalance === 0n) { + enqueueSnackbar('You have 0 USDC to send.', { variant: 'warning' }) + return + } + + enqueueSnackbar('Sending USDC...', { variant: 'info' }) const usdcAmount = decimalToBaseUnits(transferAmount, USDC_DECIMALS) + if (usdcAmount > usdcBalance) { + enqueueSnackbar('Insufficient USDC balance for this transfer.', { variant: 'warning' }) + return + } + const result = await algorand.send.assetTransfer({ sender: activeAddress, signer, @@ -437,6 +724,11 @@ export default function TokenizeAsset() { ) : null, }) + + // Refresh balance after transfer to show updated amount + setTimeout(() => { + checkUsdcOptInStatus() + }, 2000) } else { // manual ASA enqueueSnackbar('Transferring asset...', { variant: 'info' }) @@ -470,11 +762,13 @@ export default function TokenizeAsset() { setReceiverAddress('') setTransferAmount('1') } catch (error) { - console.error(error) + console.error('Transfer failed', error) if (transferMode === 'algo') { enqueueSnackbar('ALGO send failed.', { variant: 'error' }) } else { - enqueueSnackbar('Transfer failed. If sending an ASA (incl. USDC), make sure the recipient has opted in.', { variant: 'error' }) + enqueueSnackbar('Transfer failed. If sending an ASA (incl. USDC), make sure the recipient has opted in.', { + variant: 'error', + }) } } finally { setTransferLoading(false) @@ -638,6 +932,41 @@ export default function TokenizeAsset() { const transferAssetIdLabel = transferMode === 'algo' ? 'Asset (ALGO)' : transferMode === 'usdc' ? 'Asset (USDC)' : 'Asset ID' + // Helper to render USDC status text + const renderUsdcStatusText = () => { + if (usdcStatusLoading) { + return Checking status... + } + if (usdcOptedIn) { + return Already opted in ✅ + } + return Required before you can receive TestNet USDC. + } + + // Helper to render opt-in button text + const renderOptInButtonText = () => { + if (usdcOptInLoading) { + return ( + + + Opting in… + + ) + } + if (usdcStatusLoading) { + return ( + + + Checking… + + ) + } + if (usdcOptedIn) { + return 'USDC opted in ✅' + } + return 'Opt in USDC' + } + return (
{/* Top header */} @@ -669,7 +998,6 @@ export default function TokenizeAsset() {
- @@ -1164,11 +1492,35 @@ export default function TokenizeAsset() {

Transfer

Send ALGO, USDC, or any ASA (including NFTs) to another wallet.

+ + {/* USDC Opt-in (only relevant for receiving USDC) */} +
+
+ USDC Opt-In: {renderUsdcStatusText()} +
+ Asset ID: {TESTNET_USDC_ASSET_ID} +
+
+ + +
+ {/* TestNet USDC helper */}
- Need TestNet USDC? Use Circle’s faucet, then transfer it like any ASA. + Need TestNet USDC? Use Circle's faucet, then transfer it like any ASA. Note: you may need to opt-in to the USDC asset before receiving it.