diff --git a/.eslintrc.js b/.eslintrc.js index b8d3583656..41c8d09a12 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,4 +1,9 @@ module.exports = { + // Stop config cascading at the repo root — without this, linting inside a + // nested checkout (e.g. a git worktree under .claude/worktrees/) merges the + // outer checkout's config and ESLint aborts on the twice-resolved prettier + // plugin. + root: true, extends: [ 'next/core-web-vitals', 'prettier', diff --git a/.gitignore b/.gitignore index 86ea3e6c8a..c6fbe9c9be 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ yarn-debug.log* yarn-error.log* # local env files +.env .env.local .env.development.local .env.test.local diff --git a/package.json b/package.json index 9c6a695686..7d730ae0fe 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "protobufjs": "^7.5.5", "qs": "^6.14.1", "@types/react": "^18.3.30", - "@types/react-dom": "^18.3.7" + "@types/react-dom": "^18.3.7", + "bignumber.js": "^9.3.1" }, "scripts": { "dev": "next dev", @@ -50,6 +51,8 @@ "@emotion/react": "11.10.4", "@emotion/server": "latest", "@emotion/styled": "11.10.4", + "@funkit/chains": "^2.0.0", + "@funkit/connect": "^9.22.0", "@heroicons/react": "^1.0.6", "@lingui/core": "^4.14.0", "@lingui/react": "^4.14.1", @@ -99,6 +102,7 @@ "remark-gfm": "^3.0.1", "sonner": "^2.0.3", "tiny-invariant": "^1.3.1", + "tronweb": "^6.0.4", "viem": "2.45.1", "wagmi": "^2.15.2", "zustand": "^5.0.2" @@ -166,4 +170,4 @@ "budgetPercentIncreaseRed": 20, "showDetails": true } -} +} \ No newline at end of file diff --git a/pages/_app.page.tsx b/pages/_app.page.tsx index 63c44c7ec6..0d86db1a93 100644 --- a/pages/_app.page.tsx +++ b/pages/_app.page.tsx @@ -1,5 +1,8 @@ import '/public/fonts/inter/inter.css'; import '/src/styles/variables.css'; +// Preflight must come before funkit's own styles so funkit rules win source-order ties. +import '/src/ui-config/funkit/funkitPreflight.css'; +import '@funkit/connect/styles.css'; import { AaveClient, AaveProvider } from '@aave/react'; import { CacheProvider, EmotionCache } from '@emotion/react'; @@ -51,6 +54,16 @@ const BridgeModal = dynamic(() => import('src/components/transactions/Bridge/BridgeModal').then((module) => module.BridgeModal) ); +// ssr: false (unlike the other modal hosts) because `@funkit/connect` is a +// client-only, ESM/browser package. +const FunkitCheckout = dynamic( + () => + import('src/components/transactions/FunCheckout/FunkitCheckout').then( + (module) => module.FunkitCheckout + ), + { ssr: false } +); + const BorrowModal = dynamic(() => import('src/components/transactions/Borrow/BorrowModal').then((module) => module.BorrowModal) ); @@ -164,6 +177,7 @@ export default function MyApp(props: MyAppProps) { {getLayout()} + diff --git a/src/components/transactions/FunCheckout/FunSupplyButton.tsx b/src/components/transactions/FunCheckout/FunSupplyButton.tsx new file mode 100644 index 0000000000..3e3ea7a4b4 --- /dev/null +++ b/src/components/transactions/FunCheckout/FunSupplyButton.tsx @@ -0,0 +1,88 @@ +import { Trans } from '@lingui/macro'; +import { Box, Button, ButtonProps } from '@mui/material'; +import { ReactNode } from 'react'; + +import { useFunSupplyATokenIcon } from './useFunSupplyATokenIcon'; +import { useSupplyButtonAction } from './useSupplyButtonAction'; + +export type FunSupplyButtonProps = Omit & { + /** Reserve underlying address (matched case-insensitively against the allowlist). */ + underlyingAsset: string; + /** Reserve display name — forwarded to the native supply modal fallback. */ + name: string; + /** Patched display symbol of the underlying (drives the funkit checkout title). */ + symbol: string; + /** + * Symbol used to generate the ringed aToken icon. Often equals `symbol`, but + * can differ (e.g. wrapped tokens), so it's passed explicitly. + */ + iconSymbol: string; + /** Aave's `supplyAPY` — a 0–1 fraction. */ + supplyAPY: string | number; + /** Collateral flag shown in the funkit checkout (`collateralizationEnabled`). */ + collateralEnabled: boolean; + /** Analytics funnel for the native supply modal fallback. Defaults to `'dashboard'`. */ + funnel?: string; + /** The native supply modal's reserve-page flag (`openSupply`'s 5th arg). Defaults to `false`. */ + isReserve?: boolean; + /** Button label. Defaults to a translated "Supply". */ + children?: ReactNode; +}; + +/** + * The Supply button, everywhere. It owns the funkit branch so individual call + * sites can't forget it: renders the hidden ringed-aToken icon generator and + * routes the click through `useSupplyButtonAction` (funkit checkout for the + * allowlisted Core-mainnet assets, native Aave supply modal otherwise). Every + * MUI `Button` prop (`sx`, `variant`, `disabled`, `fullWidth`, `data-cy`, …) + * passes straight through, so it drops into any layout. + * + * Adding a new Supply entry point? Render this instead of calling `openSupply` + * directly — keeping the funkit branch in one place is the whole point (ENG-4228). + */ +export function FunSupplyButton({ + underlyingAsset, + name, + symbol, + iconSymbol, + supplyAPY, + collateralEnabled, + funnel, + isReserve, + children, + ...buttonProps +}: FunSupplyButtonProps) { + const handleSupplyClick = useSupplyButtonAction({ funnel, isReserve }); + const { aTokenBase64, generator } = useFunSupplyATokenIcon(underlyingAsset, iconSymbol); + + return ( + <> + {/* Hidden ringed-aToken icon generator (fun-routed rows only). Wrapped out + of flow so it never participates as a flex/grid item in the host layout + — it's a 0×0 element and would otherwise take a slot / introduce a gap. */} + {generator && ( + + {generator} + + )} + + + ); +} + +export default FunSupplyButton; diff --git a/src/components/transactions/FunCheckout/FunkitCheckout.tsx b/src/components/transactions/FunCheckout/FunkitCheckout.tsx new file mode 100644 index 0000000000..ccb0fd71eb --- /dev/null +++ b/src/components/transactions/FunCheckout/FunkitCheckout.tsx @@ -0,0 +1,142 @@ +import { + type FunkitCheckoutConfig, + FunkitProvider, + useActiveTheme, + useFunkitCheckout, +} from '@funkit/connect'; +import { useTheme } from '@mui/material'; +import { useQueryClient } from '@tanstack/react-query'; +import { useModal } from 'connectkit'; +import { useCallback, useEffect, useRef } from 'react'; +import { aaveTheme } from 'src/ui-config/funkit/aaveTheme'; +import { funkitConfig } from 'src/ui-config/funkit/funkitConfig'; +import { queryKeysFactory } from 'src/ui-config/queries'; +import { getAddress } from 'viem'; +import { useAccount } from 'wagmi'; + +import { buildFunSupplyConfig, FunSupplyReserve } from './funSupplyAssets'; +import { registerFunSupply } from './funSupplyBridge'; + +/** + * funkit checkout host. Mounted once in `_app` alongside the app's other modal + * hosts (SupplyModal etc.), as an `ssr: false` island — `@funkit/connect` is + * client-only. `FunkitProvider` is mounted WITHOUT a `wagmiConfig`/`queryClient`, + * so it reuses the interface's existing wagmi + react-query (and the wallet the + * user connected via ConnectKit). + * + * `InnerCheckout` runs inside the provider, owns the single `useFunkitCheckout` + * instance, and registers `beginSupply` on the module bridge (`funSupplyBridge`) + * for the Supply buttons to invoke. Per-asset configs are passed at call time + * via funkit's supported `beginCheckout(configOverride)`. + */ + +// Placeholder config for the hook — never opened directly; every `beginCheckout` +// call passes a full per-asset override built by `buildFunSupplyConfig`. +const PLACEHOLDER_CONFIG: FunkitCheckoutConfig = { + checkoutItemTitle: '', + targetAsset: getAddress('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'), + targetAssetTicker: '', + targetChain: '1', +}; + +function InnerCheckout() { + const { address } = useAccount(); + const { setOpen: setConnectModalOpen } = useModal(); + const queryClient = useQueryClient(); + const muiTheme = useTheme(); + const mode = muiTheme.palette.mode; + const { themeColorScheme, toggleTheme } = useActiveTheme(); + + const onSuccess = useCallback(() => { + // Same refresh the native supply flow performs on tx success + // (SupplyActions.tsx / useTransactionHandler), so the dashboard shows the + // new aToken balance immediately after the funkit checkout completes. + queryClient.invalidateQueries({ queryKey: queryKeysFactory.pool }); + queryClient.invalidateQueries({ queryKey: queryKeysFactory.gho }); + }, [queryClient]); + + // Mid-checkout connection requests (e.g. switching the payment source to a + // wallet) soft-hide the checkout modal and hand us a resume callback — the SDK + // requires `onLoginFinished()` be called after login, or the modal stays + // hidden. Stash it; the address effect below fires it once ConnectKit connects. + const onLoginFinishedRef = useRef<(() => void) | null>(null); + + const { beginCheckout } = useFunkitCheckout({ + config: PLACEHOLDER_CONFIG, + // funkit's own connect modal is unavailable when sharing the host wagmi + // (no funkit wallet list), so route login through the app's ConnectKit modal. + onLoginRequired: useCallback( + ({ onLoginFinished }: { onLoginFinished?: () => void }) => { + onLoginFinishedRef.current = onLoginFinished ?? null; + setConnectModalOpen(true); + }, + [setConnectModalOpen] + ), + onError: useCallback((error: unknown) => console.error('[FunkitCheckout]', error), []), + onSuccess, + }); + + useEffect(() => { + if (address && onLoginFinishedRef.current) { + onLoginFinishedRef.current(); + onLoginFinishedRef.current = null; + } + }, [address]); + + // Keep the funkit modal's active theme in sync with the app's color mode. + // `toggleTheme`'s identity changes on every FunkitThemeProvider render and its + // body always sets state, so the `themeColorScheme !== mode` guard is what makes + // this effect converge instead of update-looping. + useEffect(() => { + if (themeColorScheme !== mode) { + toggleTheme(mode); + } + }, [mode, themeColorScheme, toggleTheme]); + + const beginSupply = async (reserve: FunSupplyReserve) => { + // funkit checkout needs a connected wallet (read-only/watch mode has none); + // open the app's wallet modal first. + if (!address) { + setConnectModalOpen(true); + return; + } + const config = buildFunSupplyConfig(reserve, address); + if (!config) { + return; + } + const { isActivated } = await beginCheckout(config); + if (!isActivated) { + // Checkout can be remotely deactivated per API key; surface it instead + // of failing silently. + console.warn('[FunkitCheckout] checkout is not activated for this API key'); + } + }; + + // Register on the bridge once; the ref keeps the registered wrapper pointing + // at the latest impl without re-registering each render. The catch keeps a + // beginCheckout rejection from surfacing as an unhandled rejection (the bridge + // is fire-and-forget). + const beginSupplyRef = useRef(beginSupply); + useEffect(() => { + beginSupplyRef.current = beginSupply; + }); + useEffect( + () => + registerFunSupply((reserve) => { + beginSupplyRef.current(reserve).catch((error) => console.error('[FunkitCheckout]', error)); + }), + [] + ); + + return null; +} + +export function FunkitCheckout() { + return ( + + + + ); +} + +export default FunkitCheckout; diff --git a/src/components/transactions/FunCheckout/funSupplyAssets.ts b/src/components/transactions/FunCheckout/funSupplyAssets.ts new file mode 100644 index 0000000000..15afcf3b25 --- /dev/null +++ b/src/components/transactions/FunCheckout/funSupplyAssets.ts @@ -0,0 +1,106 @@ +import type { FunkitCheckoutConfig } from '@funkit/connect'; +import { createAaveSupplyCheckoutConfig } from '@funkit/connect/clients/aave'; +import { CustomMarket } from 'src/ui-config/marketsConfig'; +import { type Address, getAddress } from 'viem'; + +/** + * fun checkout only ships on the Core mainnet market. Gating on the market key + * (not chainId) matters: mainnet hosts three markets (Core, Prime/Lido, EtherFi) + * and e.g. USDC exists in all three with a different aToken and pool in each. + * With the market pinned here, every address the reserve hands us (underlying, + * aToken, pool) is consistent by construction. + */ +const FUN_SUPPLY_MARKET = CustomMarket.proto_mainnet_v3; + +/** + * The product allowlist — the underlyings (lowercased) whose Supply button + * routes through funkit's checkout modal instead of the native Aave supply + * modal. Everything else about these assets (symbols, decimals, addresses, + * icons) comes from live app state at click time. + */ +export const FUN_SUPPLY_UNDERLYINGS: ReadonlySet = new Set([ + '0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf', // cbBTC + '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599', // WBTC + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC + '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT +]); + +/** True when this market+asset's Supply button should open the funkit modal. */ +export function isFunSupplyAsset(market: CustomMarket, underlyingAsset: string): boolean { + return market === FUN_SUPPLY_MARKET && FUN_SUPPLY_UNDERLYINGS.has(underlyingAsset.toLowerCase()); +} + +/** + * Reserve snapshot the Supply button hands off when opening the fun modal. + * Display fields come from the clicked dashboard reserve; receipt-token fields + * come from the SDK reserve in `useAppDataContext().supplyReserves` (its + * `aToken`/`underlyingToken` are `@aave/graphql` `Currency` objects — the same + * source AddTokenDropdown renders on reserve-overview). + */ +export type FunSupplyReserve = { + underlyingAsset: string; + /** Patched display symbol of the underlying (the list item's `symbol`). */ + symbol: string; + /** + * Ringed aToken icon (Base64Token data URI), generated by the supply row via + * useFunSupplyATokenIcon — the same image the native flow registers via + * wallet_watchAsset. Undefined when generation hasn't completed (or was + * skipped); the hosted `aToken.imageUrl` is the fallback. + */ + aTokenBase64?: string; + /** Aave's `supplyAPY` — a 0–1 fraction string (e.g. "0.0283"). */ + supplyAPY: string | number; + /** The user's collateral toggle for this reserve (`usageAsCollateralEnabledOnUser`). */ + collateralEnabled: boolean; + chainId: number; + /** The market's pool (`currentMarketData.addresses.LENDING_POOL`). */ + poolAddress: string; + /** `sdkReserve.underlyingToken.imageUrl` — absolute URL. */ + underlyingImageUrl: string; + /** `sdkReserve.aToken` — the receipt token's own on-chain metadata. */ + aToken: { address: string; symbol: string; decimals: number; imageUrl: string }; +}; + +// Aave's supplyAPY is a 0–1 fraction; funkit's `display.supplyAPY` wants a +// percent string without the % sign (e.g. "2.83"). Rates that would render as +// 0.00 floor to "<0.01" (Aave's FormattedNumber convention) — the modal styles +// the APY as earnings-green, which a literal 0.00% would contradict. funkit +// only string-interpolates this value, so the non-numeric form is safe. +function toPercentString(apy: string | number): string { + const fraction = Number(apy); + if (!Number.isFinite(fraction)) { + return '0'; + } + const percent = (fraction * 100).toFixed(2); + return percent === '0.00' ? '<0.01' : percent; +} + +/** + * Builds the per-asset funkit checkout config. The allowlist+market gate lives + * at the click site (`useSupplyButtonAction`); this trusts the vetted reserve. + * Returns `undefined` only when the chain isn't fun-supported + * (createAaveSupplyCheckoutConfig's own signal). + */ +export function buildFunSupplyConfig( + reserve: FunSupplyReserve, + walletAddress: Address | undefined +): FunkitCheckoutConfig | undefined { + return createAaveSupplyCheckoutConfig({ + underlyingAsset: getAddress(reserve.underlyingAsset), + poolAddress: getAddress(reserve.poolAddress), + chainId: reserve.chainId, + walletAddress, + display: { + symbol: reserve.symbol, + supplyAPY: toPercentString(reserve.supplyAPY), + collateralizationEnabled: reserve.collateralEnabled, + iconSrc: reserve.underlyingImageUrl, + }, + receiptToken: { + address: getAddress(reserve.aToken.address), + symbol: reserve.aToken.symbol, + decimals: reserve.aToken.decimals, + iconSrc: reserve.aTokenBase64 ?? reserve.aToken.imageUrl, + }, + }); +} diff --git a/src/components/transactions/FunCheckout/funSupplyBridge.ts b/src/components/transactions/FunCheckout/funSupplyBridge.ts new file mode 100644 index 0000000000..8c2aaaa004 --- /dev/null +++ b/src/components/transactions/FunCheckout/funSupplyBridge.ts @@ -0,0 +1,34 @@ +import type { FunSupplyReserve } from './funSupplyAssets'; + +/** + * Imperative bridge between the Supply buttons (pre-rendered tree) and funkit's + * checkout (an `ssr: false` island — `@funkit/connect` is client-only, and this + * app pre-renders/static-exports). The island registers its `beginSupply` impl + * on mount; buttons invoke it at click time. No context/state on purpose: React + * never renders from this value, so subscription machinery would be dead weight + * (it's also what previously forced the setState + ref-dance layers). + */ +let impl: ((reserve: FunSupplyReserve) => void) | null = null; + +/** Called by FunkitCheckout on mount. Returns an unregister cleanup. */ +export function registerFunSupply(fn: (reserve: FunSupplyReserve) => void): () => void { + impl = fn; + return () => { + if (impl === fn) { + impl = null; + } + }; +} + +/** + * Opens the funkit checkout for `reserve`. Returns false when the island hasn't + * mounted yet (dynamic chunk still loading) — callers fall back to the native + * supply modal instead of dropping the click. + */ +export function beginFunSupply(reserve: FunSupplyReserve): boolean { + if (!impl) { + return false; + } + impl(reserve); + return true; +} diff --git a/src/components/transactions/FunCheckout/useFunSupplyATokenIcon.tsx b/src/components/transactions/FunCheckout/useFunSupplyATokenIcon.tsx new file mode 100644 index 0000000000..b223b385b1 --- /dev/null +++ b/src/components/transactions/FunCheckout/useFunSupplyATokenIcon.tsx @@ -0,0 +1,31 @@ +import { ReactNode, useState } from 'react'; +import { Base64Token } from 'src/components/primitives/TokenIcon'; +import { useRootStore } from 'src/store/root'; + +import { isFunSupplyAsset } from './funSupplyAssets'; + +/** + * Generates the ringed aToken icon (underlying icon wrapped in Aave's gradient + * TokenRing, as a base64 data URI) for fun-routed supply rows — the same image + * the native flow registers via wallet_watchAsset (Success.tsx / + * AddTokenDropdown pattern: hidden Base64Token + state, ready long before the + * click). Returns the icon plus the hidden generator element the row must + * render. Both are undefined/null for rows that aren't fun-routed. + */ +export function useFunSupplyATokenIcon( + underlyingAsset: string, + iconSymbol: string +): { aTokenBase64: string | undefined; generator: ReactNode } { + const currentMarket = useRootStore((store) => store.currentMarket); + const [aTokenBase64, setATokenBase64] = useState(''); + + // Same render condition as the native flows: only fun-routed rows, and + // Base64Token can't compose multi-part symbols (e.g. LP tokens). + const shouldGenerate = isFunSupplyAsset(currentMarket, underlyingAsset) && !/_/.test(iconSymbol); + + const generator = shouldGenerate ? ( + + ) : null; + + return { aTokenBase64: aTokenBase64 || undefined, generator }; +} diff --git a/src/components/transactions/FunCheckout/useSupplyButtonAction.tsx b/src/components/transactions/FunCheckout/useSupplyButtonAction.tsx new file mode 100644 index 0000000000..ecc38a6e16 --- /dev/null +++ b/src/components/transactions/FunCheckout/useSupplyButtonAction.tsx @@ -0,0 +1,80 @@ +import { useAppDataContext } from 'src/hooks/app-data-provider/useAppDataProvider'; +import { useModalContext } from 'src/hooks/useModal'; +import { useRootStore } from 'src/store/root'; + +import { isFunSupplyAsset } from './funSupplyAssets'; +import { beginFunSupply } from './funSupplyBridge'; + +/** Fields a Supply list item passes when its button is clicked. */ +export type SupplyButtonReserve = { + underlyingAsset: string; + name: string; + symbol: string; + /** Ringed aToken icon data URI from useFunSupplyATokenIcon (fun-routed rows only). */ + aTokenBase64?: string; + /** Aave's `supplyAPY` — a 0–1 fraction. */ + supplyAPY: string | number; + /** `usageAsCollateralEnabledOnUser` from the reserve. */ + collateralEnabled: boolean; +}; + +/** Call-site-constant options for the native `openSupply` fallback. */ +export type SupplyButtonActionOptions = { + /** Analytics funnel passed to `openSupply`. Defaults to `'dashboard'`. */ + funnel?: string; + /** `openSupply`'s `isReserve` flag (set by the reserve-overview page). Defaults to `false`. */ + isReserve?: boolean; +}; + +/** + * Returns the Supply button's click handler. For the allowlisted assets on the + * Core mainnet market it opens the funkit checkout modal; for everything else it + * falls back to the native Aave supply modal (`openSupply`). Consumed by + * `FunSupplyButton`, which is the single Supply button used across the app, so + * the funkit branch lives in one place. + * + * `funnel`/`isReserve` parameterize only the native fallback so each entry point + * (dashboard lists vs. the reserve-overview page) keeps its existing + * analytics/navigation behavior. + * + * Receipt-token metadata (the aToken's address/symbol/decimals/icon) comes from + * the SDK reserve already in app state — no integrator-owned copies. + */ +export function useSupplyButtonAction( + options?: SupplyButtonActionOptions +): (reserve: SupplyButtonReserve) => void { + const funnel = options?.funnel ?? 'dashboard'; + const isReserve = options?.isReserve ?? false; + const currentMarket = useRootStore((store) => store.currentMarket); + const currentMarketData = useRootStore((store) => store.currentMarketData); + const { supplyReserves } = useAppDataContext(); + const { openSupply } = useModalContext(); + + return (reserve: SupplyButtonReserve) => { + if (isFunSupplyAsset(currentMarket, reserve.underlyingAsset)) { + const sdkReserve = supplyReserves.find( + (r) => r.underlyingToken.address.toLowerCase() === reserve.underlyingAsset.toLowerCase() + ); + const handled = + !!sdkReserve && + beginFunSupply({ + underlyingAsset: reserve.underlyingAsset, + symbol: reserve.symbol, + aTokenBase64: reserve.aTokenBase64, + supplyAPY: reserve.supplyAPY, + collateralEnabled: reserve.collateralEnabled, + chainId: currentMarketData.chainId, + poolAddress: currentMarketData.addresses.LENDING_POOL, + underlyingImageUrl: sdkReserve.underlyingToken.imageUrl, + aToken: sdkReserve.aToken, + }); + if (handled) { + return; + } + // Fall through to the native modal when the funkit island hasn't mounted + // yet (ssr:false chunk still loading) or the SDK market data isn't in + // yet — instead of dropping the click. + } + openSupply(reserve.underlyingAsset, currentMarket, reserve.name, funnel, isReserve); + }; +} diff --git a/src/hooks/useReserveActionState.tsx b/src/hooks/useReserveActionState.tsx index bd5f4ff3c6..6a9db8200e 100644 --- a/src/hooks/useReserveActionState.tsx +++ b/src/hooks/useReserveActionState.tsx @@ -4,6 +4,7 @@ import { Button, Stack, SvgIcon, Typography } from '@mui/material'; import { Link, ROUTES } from 'src/components/primitives/Link'; import { Warning } from 'src/components/primitives/Warning'; import { getEmodeMessage } from 'src/components/transactions/Emode/EmodeNaming'; +import { isFunSupplyAsset } from 'src/components/transactions/FunCheckout/funSupplyAssets'; import { ComputedReserveData, useAppDataContext, @@ -53,8 +54,16 @@ export const useReserveActionState = ({ const isGho = displayGhoForMintableMarket({ symbol: reserve.symbol, currentMarket }); + // fun-routed assets can be supplied from any EVM asset / fiat via the funkit + // checkout, so an empty wallet (balance / maxAmountToSupply === '0') shouldn't + // disable the button — only a maxed supply cap does (mirrors SupplyAssetsList). + // fun assets are never GHO, so that guard is moot for them. + const isFunSupply = isFunSupplyAsset(currentMarket, reserve.underlyingAsset); + return { - disableSupplyButton: balance === '0' || maxAmountToSupply === '0' || isGho, + disableSupplyButton: isFunSupply + ? !!supplyCap?.isMaxed + : balance === '0' || maxAmountToSupply === '0' || isGho, disableBorrowButton: !assetCanBeBorrowedFromPool || userHasNoCollateralSupplied || diff --git a/src/locales/en/messages.po b/src/locales/en/messages.po index a1b98a0cf7..5f1c589c12 100644 --- a/src/locales/en/messages.po +++ b/src/locales/en/messages.po @@ -2560,15 +2560,13 @@ msgid "supply" msgstr "supply" #: pages/dashboard.page.tsx +#: src/components/transactions/FunCheckout/FunSupplyButton.tsx #: src/components/transactions/Supply/SupplyModal.tsx #: src/modules/dashboard/lists/SuppliedPositionsList/SuppliedPositionsListItem.tsx #: src/modules/dashboard/lists/SuppliedPositionsList/SuppliedPositionsListMobileItem.tsx -#: src/modules/dashboard/lists/SupplyAssetsList/SupplyAssetsListItem.tsx -#: src/modules/dashboard/lists/SupplyAssetsList/SupplyAssetsListItem.tsx #: src/modules/dashboard/lists/SupplyAssetsList/SupplyAssetsListMobileItem.tsx #: src/modules/history/actions/ActionDetails.tsx #: src/modules/history/HistoryFilterMenu.tsx -#: src/modules/reserve-overview/ReserveActions.tsx msgid "Supply" msgstr "Supply" diff --git a/src/modules/dashboard/lists/SupplyAssetsList/SupplyAssetsList.tsx b/src/modules/dashboard/lists/SupplyAssetsList/SupplyAssetsList.tsx index e59b0b6a7f..12864d5841 100644 --- a/src/modules/dashboard/lists/SupplyAssetsList/SupplyAssetsList.tsx +++ b/src/modules/dashboard/lists/SupplyAssetsList/SupplyAssetsList.tsx @@ -9,6 +9,7 @@ import { ListColumn } from 'src/components/lists/ListColumn'; import { ListHeaderTitle } from 'src/components/lists/ListHeaderTitle'; import { ListHeaderWrapper } from 'src/components/lists/ListHeaderWrapper'; import { Warning } from 'src/components/primitives/Warning'; +import { isFunSupplyAsset } from 'src/components/transactions/FunCheckout/funSupplyAssets'; import { AssetCapsProvider } from 'src/hooks/useAssetCaps'; import { useCoingeckoCategories } from 'src/hooks/useCoinGeckoCategories'; import { useWrappedTokens } from 'src/hooks/useWrappedTokens'; @@ -202,6 +203,12 @@ export const SupplyAssetsList = () => { ); const filteredSupplyReserves = sortedSupplyReserves.filter((reserve) => { + // fun-routed assets can be supplied from any EVM asset / fiat via the funkit + // checkout, so an empty wallet must not hide them. + if (isFunSupplyAsset(currentMarket, reserve.underlyingAsset)) { + return true; + } + // Filter out dust amounts < $0.01 USD if (reserve.availableToDepositUSD !== '0' && Number(reserve.availableToDepositUSD) >= 0.01) { return true; diff --git a/src/modules/dashboard/lists/SupplyAssetsList/SupplyAssetsListItem.tsx b/src/modules/dashboard/lists/SupplyAssetsList/SupplyAssetsListItem.tsx index 4b406c59e8..4f7023053a 100644 --- a/src/modules/dashboard/lists/SupplyAssetsList/SupplyAssetsListItem.tsx +++ b/src/modules/dashboard/lists/SupplyAssetsList/SupplyAssetsListItem.tsx @@ -21,6 +21,8 @@ import { FormattedNumber } from 'src/components/primitives/FormattedNumber'; import { NoData } from 'src/components/primitives/NoData'; import { Row } from 'src/components/primitives/Row'; import { TokenIcon } from 'src/components/primitives/TokenIcon'; +import { isFunSupplyAsset } from 'src/components/transactions/FunCheckout/funSupplyAssets'; +import { FunSupplyButton } from 'src/components/transactions/FunCheckout/FunSupplyButton'; import { WalletBalancesMap } from 'src/hooks/app-data-provider/useWalletBalances'; import { useAssetCaps } from 'src/hooks/useAssetCaps'; import { useModalContext } from 'src/hooks/useModal'; @@ -50,6 +52,7 @@ export const SupplyAssetsListItem = ( const downToXSM = useMediaQuery(theme.breakpoints.down('xsm')); const { supplyCap } = useAssetCaps(); const wrappedTokenReserves = useWrappedTokens(); + const currentMarket = useRootStore((store) => store.currentMarket); const { isActive, isFreezed, walletBalance, underlyingAsset } = params; @@ -61,10 +64,15 @@ export const SupplyAssetsListItem = ( wrappedToken && params.walletBalances[wrappedToken.tokenIn.underlyingAsset.toLowerCase()].amount !== '0'; + // fun-routed assets can be supplied from any EVM asset / fiat via the funkit + // checkout, so an empty wallet doesn't block supplying them (protocol-level + // blocks — inactive/frozen/capped — still apply). + const isFunSupply = isFunSupplyAsset(currentMarket, underlyingAsset); + const disableSupply = !isActive || isFreezed || - (Number(walletBalance) <= 0 && !canSupplyAsWrappedToken) || + (Number(walletBalance) <= 0 && !canSupplyAsWrappedToken && !isFunSupply) || supplyCap.isMaxed; const props: SupplyAssetsListItemProps = { @@ -110,7 +118,7 @@ export const SupplyAssetsListItemDesktop = ({ const currentMarket = useRootStore((store) => store.currentMarket); const wrappedTokenReserves = useWrappedTokens(); - const { openSupply, openSwitch } = useModalContext(); + const { openSwitch } = useModalContext(); // Disable the asset to prevent it from being supplied if supply cap has been reached const { supplyCap: supplyCapUsage, debtCeiling } = useAssetCaps(); @@ -239,15 +247,15 @@ export const SupplyAssetsListItemDesktop = ({ - + underlyingAsset={underlyingAsset} + name={name} + symbol={symbol} + iconSymbol={iconSymbol} + supplyAPY={supplyAPY} + collateralEnabled={usageAsCollateralEnabledOnUser} + /> + /> + underlyingAsset={underlyingAsset} + name={reserve.name} + symbol={symbol} + iconSymbol={reserve.iconSymbol} + supplyAPY={reserve.supplyAPY} + collateralEnabled={collateralEnabled} + funnel="reserve" + isReserve + /> ); diff --git a/src/ui-config/funkit/aaveTheme.ts b/src/ui-config/funkit/aaveTheme.ts new file mode 100644 index 0000000000..5a73da00b7 --- /dev/null +++ b/src/ui-config/funkit/aaveTheme.ts @@ -0,0 +1,208 @@ +import { type ThemeOptions, darkTheme, lightTheme } from '@funkit/connect'; + +/** + * Aave funkit theme, ported *almost* verbatim from the funkit playground's customer theme + * (`funkit:apps/with-next/themes/aave.ts`) — the Figma-derived theme the fun team + * maintains for Aave (Customer-Themes file). + */ + +// The canonical aave theme (sizings included) was designed and QA'd against the +// with-next playground's body font — a system-first stack that renders SF Pro on +// macOS. The interface's body font is Inter, whose taller metrics make the same +// sizings look heavier/cramped (and clip in tight line-heights), so instead of +// `customFontFamily: 'inherit'` we pin the modal to the playground's exact stack +// to match the reference rendering pixel-for-pixel. If design wants the modal in +// Inter (the app font), the sizings below need an Inter-specific re-tune first. +const customFontFamily = + "-apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', 'Roboto', sans-serif"; + +const customFontSizings: ThemeOptions['customFontSizings'] = { + // Bump fontSize by 1px, keep lineHeight + '10': { fontSize: '11px', lineHeight: '16px' }, + '12': { fontSize: '13px', lineHeight: '15px' }, + '13': { fontSize: '14px', lineHeight: '19px' }, + '14': { fontSize: '15px', lineHeight: '19px' }, + '16': { fontSize: '17px', lineHeight: '21px' }, + '18': { fontSize: '19px', lineHeight: '25px' }, + '20': { fontSize: '21px', lineHeight: '21px' }, + '21': { fontSize: '22px', lineHeight: '22px' }, + '40': { fontSize: '41px', lineHeight: '49px' }, + '57': { fontSize: '58px', lineHeight: '69px' }, + modalTopbarTitle: { fontSize: '21px', lineHeight: '24px' }, + modalBottomBarButtonText: { fontSize: '13px', lineHeight: '16px' }, +}; + +const customBorderRadiuses = { + modal: '4px', + modalMobile: '4px', + modalActionButton: '4px', + modalActionButtonMobile: '4px', + connectButton: '4px', + qrCode: '4px', + tooltip: '4px', + skeleton: '4px', + actionButton: '4px', + actionButtonInner: '3px', + menuButton: '4px', + summaryBox: '4px', + dropdownItem: '4px', + youPayYouReceive: '50px', + inputAmountSwitcher: '4px', + dropdown: '4px', +}; + +// Soft green-yellow gradient composited as a translucent overlay on top of +// the modal background during the post-checkout success state. Values +// lifted from Figma node 10852:39855 (Customer-Themes file); the solid +// white base layer from the Figma export is intentionally omitted so the +// theme's modalBackground (white in light, dark in dark) shows through. +const aaveCheckoutCompleteGradient = [ + 'linear-gradient(71.67deg, rgba(225,253,16,0) 74.39%, rgba(194,204,0,0.1) 98.07%)', + 'linear-gradient(-23.68deg, rgba(0,204,112,0) 72.26%, rgba(0,204,88,0.1) 88.40%)', + 'linear-gradient(-7.43deg, rgba(102,204,0,0) 57.00%, rgba(102,204,0,0.1) 97.77%)', +].join(', '); + +const darkThemeColors = { + primaryText: '#F1F1F3', + secondaryText: '#A5A8B5', + tertiaryText: '#A5A8B5', + lightStroke: '#393C4D', + mediumStroke: '#393C4D', + heavyStroke: '#414558', + modalBackground: '#2A2E40', + actionColor: '#FFFFFF', + offBackground: '#393D4F', + offBackgroundInverse: '#A5A8B5', + secondaryBackground: '#42475C', +}; + +const darkThemeObject = darkTheme({ + customFontFamily, + customColors: { + ...darkThemeColors, + modalBackgroundCheckoutComplete: aaveCheckoutCompleteGradient, + modalHeaderDivider: darkThemeColors.mediumStroke, + modalFooterDivider: darkThemeColors.mediumStroke, + modalBorder: darkThemeColors.lightStroke, + + buttonTextPrimary: '#2A2E40', + buttonTextHover: '#2A2E40', + buttonTextDisabled: 'rgba(42, 46, 64, 0.9)', + + buttonBackground: darkThemeColors.actionColor, + buttonBackgroundHover: '#D2D4DB', + buttonBackgroundPressed: '#D2D4DB', + buttonBackgroundDisabled: 'rgba(255, 255, 255, 0.5)', + + buttonTextTertiary: '#F1F1F3', + buttonTextDisabledTertiary: 'rgba(241, 241, 243, 0.5)', + buttonBackgroundTertiary: darkThemeColors.offBackground, + buttonBackgroundHoverTertiary: darkThemeColors.secondaryBackground, + buttonBackgroundDisabledTertiary: 'rgba(57, 61, 79, 0.5)', + + youPayYouReceiveBorder: darkThemeColors.mediumStroke, + youPayYouReceiveBackground: darkThemeColors.modalBackground, + inputAmountQuickOptionBaseBackground: darkThemeColors.offBackground, + inputAmountQuickOptionHoverBackground: darkThemeColors.secondaryBackground, + focusedOptionBorder: darkThemeColors.actionColor, + modalTopbarIcon: darkThemeColors.secondaryText, + modalTopbarIconBackgroundHover: darkThemeColors.secondaryBackground, + modalTopbarIconBackgroundPressed: darkThemeColors.secondaryBackground, + buttonIconBackgroundHover: darkThemeColors.secondaryBackground, + buttonBorderFocusedTertiary: darkThemeColors.mediumStroke, + menuItemBackground: darkThemeColors.offBackground, + copyButtonBackgroundHover: darkThemeColors.secondaryBackground, + copyButtonBackgroundActive: darkThemeColors.secondaryBackground, + funFeatureListBackgroundHover: darkThemeColors.secondaryBackground, + activeTabBackground: darkThemeColors.secondaryBackground, + activeTabBorderColor: 'rgba(165, 168, 181, 0.1)', + cryptoCashToggleBackground: darkThemeColors.offBackground, + generalBorder: darkThemeColors.mediumStroke, + inputBorderHover: darkThemeColors.offBackground, + }, + customFontSizings, + customBorderRadiuses, + customDimensions: { + modalBottomBarButtonHeight: '40px', + }, + customSpacings: { + cryptoCashToggleTabPaddingY: '12px', + }, + overlayBlur: 'none', +}); + +const lightThemeColors = { + primaryText: '#313547', + secondaryText: '#636779', + tertiaryText: '#636779', + lightStroke: '#EAEBEF', + mediumStroke: '#EAEBEF', + heavyStroke: '#E8E9ED', + modalBackground: '#FFFFFF', + actionColor: '#393D4F', + offBackground: '#F7F7F9', + offBackgroundInverse: '#393D4F', + secondaryBackground: '#EAEBEF', +}; + +const lightThemeObject = lightTheme({ + customFontFamily, + customColors: { + ...lightThemeColors, + modalBackgroundCheckoutComplete: aaveCheckoutCompleteGradient, + modalHeaderDivider: lightThemeColors.mediumStroke, + modalFooterDivider: lightThemeColors.mediumStroke, + modalBorder: '#FFFFFF', + + buttonTextPrimary: '#FFFFFF', + buttonTextHover: '#FFFFFF', + buttonTextDisabled: 'rgba(255, 255, 255, 1)', + + buttonBackground: lightThemeColors.actionColor, + buttonBackgroundHover: '#2A2E40', + buttonBackgroundPressed: '#2A2E40', + buttonBackgroundDisabled: 'rgba(57, 61, 79, 0.45)', + + buttonTextTertiary: '#636779', + buttonTextDisabledTertiary: 'rgba(99, 103, 121, 0.5)', + buttonBackgroundTertiary: lightThemeColors.offBackground, + buttonBackgroundHoverTertiary: lightThemeColors.secondaryBackground, + buttonBackgroundDisabledTertiary: 'rgba(247, 247, 249, 0.5)', + + youPayYouReceiveBorder: lightThemeColors.mediumStroke, + youPayYouReceiveBackground: lightThemeColors.modalBackground, + inputAmountQuickOptionBaseBackground: lightThemeColors.offBackground, + inputAmountQuickOptionHoverBackground: lightThemeColors.secondaryBackground, + focusedOptionBorder: lightThemeColors.actionColor, + modalTopbarIcon: lightThemeColors.secondaryText, + modalTopbarIconBackgroundHover: lightThemeColors.secondaryBackground, + modalTopbarIconBackgroundPressed: lightThemeColors.secondaryBackground, + buttonIconBackgroundHover: lightThemeColors.secondaryBackground, + buttonBorderFocusedTertiary: lightThemeColors.mediumStroke, + menuItemBackground: lightThemeColors.offBackground, + copyButtonBackgroundHover: lightThemeColors.secondaryBackground, + copyButtonBackgroundActive: lightThemeColors.secondaryBackground, + funFeatureListBackgroundHover: lightThemeColors.secondaryBackground, + activeTabBackground: lightThemeColors.modalBackground, + activeTabBorderColor: lightThemeColors.heavyStroke, + cryptoCashToggleBackground: lightThemeColors.offBackground, + }, + customFontSizings, + customBorderRadiuses, + customShadows: { + dialog: 'rgba(0, 0, 0, 0.05) 0px 2px 1px, rgba(0, 0, 0, 0.25) 0px 0px 1px;', + }, + customDimensions: { + modalBottomBarButtonHeight: '40px', + modalTopBarHeight: '76px', + }, + customSpacings: { + cryptoCashToggleTabPaddingY: '12px', + }, + overlayBlur: 'none', +}); + +export const aaveTheme = { + darkTheme: darkThemeObject, + lightTheme: lightThemeObject, +}; diff --git a/src/ui-config/funkit/funkitConfig.tsx b/src/ui-config/funkit/funkitConfig.tsx new file mode 100644 index 0000000000..eb214625e1 --- /dev/null +++ b/src/ui-config/funkit/funkitConfig.tsx @@ -0,0 +1,42 @@ +import { ChainId } from '@aave/contract-helpers'; +import { AaveV3Ethereum } from '@aave-dao/aave-address-book'; +import { type FunkitConfig } from '@funkit/connect'; +import { networkConfigs } from 'src/ui-config/networksConfig'; + +/** + * funkit checkout configuration for the Aave interface. + * + * Mirrors the production Polymarket integration (Polymarket/polymarket-next + * `utils/constants/funkit.ts`) and the funkit playground's `aave` customer + * (`with-next/configs/customers.tsx`). The matching theme lives in + * `./aaveTheme` (ported from `with-next/themes/aave.ts`). + * + * The API key comes from `NEXT_PUBLIC_FUNKIT_API_KEY` (per the repo no-secrets + * rule); checkout is non-functional without it. + */ +export const funkitConfig: FunkitConfig = { + appName: 'Aave', + apiKey: process.env.NEXT_PUBLIC_FUNKIT_API_KEY || '', + uiCustomizations: { + alignTitle: 'left', + // Fonts for the embedded fiat-card (Swapped) widget — separate from the modal + // theme's `customFontFamily`. Named font, lowercase (Polymarket does the same; + // this app's font is Inter too). + customFontFamily: { + primary: 'inter', + }, + confirmationScreen: { + showSelectedRoute: true, + destinationConfig: { + // Served from this repo's public/ dir — same asset (and root-absolute + // path convention) as TokenIcon. + icon: Aave V3, + text: 'Aave V3', + // The supply mints aTokens to the user's own wallet; point the label at + // the Aave V3 mainnet Pool contract the checkout routes through + // (same source funSupplyAssets uses). + url: `${networkConfigs[ChainId.mainnet].explorerLink}/address/${AaveV3Ethereum.POOL}`, + }, + }, + }, +}; diff --git a/src/ui-config/funkit/funkitPreflight.css b/src/ui-config/funkit/funkitPreflight.css new file mode 100644 index 0000000000..355eb6e450 --- /dev/null +++ b/src/ui-config/funkit/funkitPreflight.css @@ -0,0 +1,48 @@ +/** + * Form-control font inheritance for the funkit modal. + * + * @funkit/connect's components put font-family/font-weight classes on *wrapper* + * elements and expect raw /