From bf0351ad7b1fef43ec08cb04ebc4658564d5d227 Mon Sep 17 00:00:00 2001 From: Jeremy Lee <37092291+yogurtandjam@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:15:35 +0200 Subject: [PATCH 1/3] feat: funkit checkout integration for Core-market supplies (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: route allowlisted Core-market supplies through funkit checkout Adds a funkit checkout path for USDC/USDT/WBTC/cbBTC supplies on the Core mainnet market, mirroring the production Polymarket integration: - FunkitCheckout: ssr:false modal host in _app (next to the app's other modal hosts), single useFunkitCheckout instance, per-asset configs via beginCheckout(configOverride). Honors the SDK's onLoginFinished resume contract by replaying it once ConnectKit connects. - funSupplyBridge: module-level imperative bridge across the client-only island boundary; clicks before the chunk loads fall back to the native supply modal. - funSupplyAssets: allowlist holds only what live reserve data can't provide (real on-chain aToken symbols — wallet_watchAsset validates against the contract — and absolute icon URLs for EIP-747). Everything else (symbol, decimals, addresses, pool) comes from the clicked reserve + current market store. Gated on CustomMarket.proto_mainnet_v3 so Prime/EtherFi reserves of the same underlyings stay native. - funkitPreflight.css: scoped, zero-specificity slice of Tailwind preflight under [data-rk] — @funkit/connect relies on host preflight for form-control font inheritance and img max-width (its production hosts are Tailwind apps; this one isn't). - patches/@funkit+connect: React 18 forwardRef shim for funkit's React 19 ref-as-prop components, applied via patch-package. Co-Authored-By: Claude Opus 4.8 (1M context) * feat: always show fun-routed assets in supply list regardless of balance fun checkout lets users supply from any EVM asset or fiat, so an empty wallet must not hide the row or disable the Supply button for allowlisted Core-market assets: - SupplyAssetsList dust filter: fun-routed reserves always pass - SupplyAssetsListItem / SupplyAssetsListMobileItem: zero wallet balance no longer disables Supply for fun-routed reserves (protocol-level blocks - inactive/frozen/paused/capped - still apply) Co-Authored-By: Claude Opus 4.8 (1M context) * test: pin FUN_SUPPLY_ASSETS aToken symbols to the address-book tokenlist The catalog hardcodes receipt-token symbols (dashboard reserve data only carries aTokenAddress; importing the 352KB/895-entry tokenlist at runtime for 4 strings is a bad bundle trade). This test imports the tokenlist where bundle size doesn't matter and fails CI if an address-book bump or catalog typo ever disagrees - wallets validate wallet_watchAsset symbols against the contract, so drift breaks the add-to-wallet flow. Loaded via a node subprocess: the package's CJS tokenlist artifact is data-less (its only module.exports is esbuild's dead-code annotation) and next/jest forbids transforming node_modules, so neither artifact is importable in-process under jest. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor: source funkit receipt-token data from app state The @aave/react SDK migration means useAppDataContext().supplyReserves already carries each reserve's aToken Currency (address, symbol, decimals, imageUrl) - the same state AddTokenDropdown renders on reserve-overview. Look it up at the Supply click instead of keeping integrator-owned copies: - FUN_SUPPLY_ASSETS catalog (hardcoded aToken symbols + icon URLs) shrinks to FUN_SUPPLY_UNDERLYINGS, a 4-address allowlist Set - purely the product decision of which assets route through fun checkout - useSupplyButtonAction resolves the SDK reserve and threads aToken/underlyingToken metadata through the bridge; falls back to the native modal if SDK market data isn't loaded yet - receipt icon improves: actual aToken artwork from aToken.imageUrl instead of reusing the underlying's icon - deletes the address-book drift test (nothing hardcoded left to drift) and its node-subprocess workaround for the tokenlist's broken CJS artifact; drops the decimals/aTokenAddress threading from list items - removes stray console.log debug block in SupplyAssetsListItemMobile Co-Authored-By: Claude Opus 4.8 (1M context) * feat: register the ringed aToken icon via fun checkout add-to-wallet The native flow's wallet icon is generated, not hosted: Base64Token wraps the underlying's local SVG in Aave's gradient TokenRing and base64-encodes it (Success.tsx / AddTokenDropdown). The SDK's aToken.imageUrl points at a plain third-party logo (token-logos.family.co), so the fun checkout's add-to-wallet icon didn't match. Per-row generation, mirroring AddTokenDropdown: fun-routed supply rows mount a hidden Base64Token via the shared useFunSupplyATokenIcon hook and pass the data URI through the click payload - explicit data flow, ready at row mount, never delays beginCheckout. Hosted imageUrl stays as the fallback for a click that beats generation. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: drop unused tronweb dependency tronweb isn't a @funkit/connect dependency or peer, isn't referenced in its bundle, and nothing in this repo imports it - a leftover from early integration setup. Its exact bignumber.js@9.1.2 pin hoisted over the repo's ^9.0.2 -> 9.3.1 resolution, hiding the UMD global type that upstream's PriceInput.tsx relies on post-rebase. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .gitignore | 3 +- .prettierignore | 2 + .yarnrc | 6 + package.json | 5 + pages/_app.page.tsx | 14 + patches/@funkit+connect+9.18.0.patch | 31 + .../FunCheckout/FunkitCheckout.tsx | 142 ++ .../FunCheckout/funSupplyAssets.ts | 102 ++ .../FunCheckout/funSupplyBridge.ts | 34 + .../FunCheckout/useFunSupplyATokenIcon.tsx | 31 + .../FunCheckout/useSupplyButtonAction.tsx | 63 + .../SupplyAssetsList/SupplyAssetsList.tsx | 7 + .../SupplyAssetsList/SupplyAssetsListItem.tsx | 49 +- .../SupplyAssetsListMobileItem.tsx | 31 +- src/ui-config/funkit/aaveTheme.ts | 208 +++ src/ui-config/funkit/funkitConfig.tsx | 43 + src/ui-config/funkit/funkitPreflight.css | 48 + tsconfig.json | 3 + yarn.lock | 1286 ++++++++++++++++- 19 files changed, 2072 insertions(+), 36 deletions(-) create mode 100644 .yarnrc create mode 100644 patches/@funkit+connect+9.18.0.patch create mode 100644 src/components/transactions/FunCheckout/FunkitCheckout.tsx create mode 100644 src/components/transactions/FunCheckout/funSupplyAssets.ts create mode 100644 src/components/transactions/FunCheckout/funSupplyBridge.ts create mode 100644 src/components/transactions/FunCheckout/useFunSupplyATokenIcon.tsx create mode 100644 src/components/transactions/FunCheckout/useSupplyButtonAction.tsx create mode 100644 src/ui-config/funkit/aaveTheme.ts create mode 100644 src/ui-config/funkit/funkitConfig.tsx create mode 100644 src/ui-config/funkit/funkitPreflight.css diff --git a/.gitignore b/.gitignore index 86ea3e6c8a..82009a03f8 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 @@ -45,7 +46,7 @@ package-lock.json .yarn-cache # TypeScript incremental build cache -tsconfig.tsbuildinfo +*.tsbuildinfo # IDE specific .idea diff --git a/.prettierignore b/.prettierignore index 604e47ce2f..f3b974748a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -26,3 +26,5 @@ src/locales/ *.mermaid *.log *.lock +*.patch +.yarnrc diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 0000000000..3bda14ca24 --- /dev/null +++ b/.yarnrc @@ -0,0 +1,6 @@ +# @funkit/connect transitively depends on @solana/addresses, whose `engines` +# field requires node >=20.18 while this repo targets node 18 (.nvmrc). yarn v1 +# re-validates engines on every install, so skip the check here instead of +# passing --ignore-engines by hand each time. The package is browser code; the +# engines field only gates the install. +ignore-engines true diff --git a/package.json b/package.json index 9c6a695686..2b0580b04e 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "i18n:extract": "NODE_ENV=development lingui extract --clean --overwrite --locale en", "i18n:compile": "lingui compile", "i18n": "yarn i18n:extract && yarn i18n:compile", + "postinstall": "patch-package", "prepare": "husky install", "test:open": "DOTENV_CONFIG_PATH='.env.local' cypress open", "test:headless": "export DOTENV_CONFIG_PATH='../../../.env.local' && cypress run --config-file './cypress/configs/local/full.config.ts'", @@ -50,6 +51,9 @@ "@emotion/react": "11.10.4", "@emotion/server": "latest", "@emotion/styled": "11.10.4", + "@funkit/api-base": "^4.4.1", + "@funkit/chains": "^1.2.0", + "@funkit/connect": "^9.18.0", "@heroicons/react": "^1.0.6", "@lingui/core": "^4.14.0", "@lingui/react": "^4.14.1", @@ -140,6 +144,7 @@ "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "lint-staged": "^13.0.3", + "patch-package": "^8.0.1", "prettier": "^2.8.1", "typescript": "^5.0.4" }, 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/patches/@funkit+connect+9.18.0.patch b/patches/@funkit+connect+9.18.0.patch new file mode 100644 index 0000000000..4e91813b04 --- /dev/null +++ b/patches/@funkit+connect+9.18.0.patch @@ -0,0 +1,31 @@ +diff --git a/node_modules/@funkit/connect/dist/index.js b/node_modules/@funkit/connect/dist/index.js +index dc8ada2..5d409f3 100644 +--- a/node_modules/@funkit/connect/dist/index.js ++++ b/node_modules/@funkit/connect/dist/index.js +@@ -18811,15 +18811,14 @@ var scrollbarHidden2 = "_1s031a01"; + function subtractScrollbarWidth(padding) { + return isSafari() ? padding : padding - SCROLL_BAR_WIDTH; + } +-function PaddedScrollableArea({ ++var PaddedScrollableArea = React64.forwardRef(function PaddedScrollableArea2({ + padding, + isScrolling, + paddingBottom, + children, + className, +- ref, + style +-}) { ++}, ref) { + return /* @__PURE__ */ React64.createElement( + Box, + { +@@ -18838,7 +18837,7 @@ function PaddedScrollableArea({ + }, + children + ); +-} ++}); + + // src/components/Dialog/FocusTrap.tsx + import React65, { useCallback as useCallback20, useEffect as useEffect27, useRef as useRef10 } from "react"; 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..0b46016bf4 --- /dev/null +++ b/src/components/transactions/FunCheckout/funSupplyAssets.ts @@ -0,0 +1,102 @@ +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"). +function toPercentString(apy: string | number): string { + const fraction = Number(apy); + if (!Number.isFinite(fraction)) { + return '0'; + } + return (fraction * 100).toFixed(2); +} + +/** + * 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..5f462698b5 --- /dev/null +++ b/src/components/transactions/FunCheckout/useSupplyButtonAction.tsx @@ -0,0 +1,63 @@ +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; +}; + +/** + * 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`). Shared by all 3 + * Supply list-item variants so the branch lives in one place. + * + * 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(): (reserve: SupplyButtonReserve) => void { + 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, 'dashboard'); + }; +} 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..685a463d6a 100644 --- a/src/modules/dashboard/lists/SupplyAssetsList/SupplyAssetsListItem.tsx +++ b/src/modules/dashboard/lists/SupplyAssetsList/SupplyAssetsListItem.tsx @@ -21,6 +21,9 @@ 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 { useFunSupplyATokenIcon } from 'src/components/transactions/FunCheckout/useFunSupplyATokenIcon'; +import { useSupplyButtonAction } from 'src/components/transactions/FunCheckout/useSupplyButtonAction'; import { WalletBalancesMap } from 'src/hooks/app-data-provider/useWalletBalances'; import { useAssetCaps } from 'src/hooks/useAssetCaps'; import { useModalContext } from 'src/hooks/useModal'; @@ -50,6 +53,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 +65,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 +119,13 @@ export const SupplyAssetsListItemDesktop = ({ const currentMarket = useRootStore((store) => store.currentMarket); const wrappedTokenReserves = useWrappedTokens(); - const { openSupply, openSwitch } = useModalContext(); + const { openSwitch } = useModalContext(); + const handleSupplyClick = useSupplyButtonAction(); + // Ringed aToken icon for the fun checkout's add-to-wallet (fun-routed rows only) + const { aTokenBase64, generator: aTokenIconGenerator } = useFunSupplyATokenIcon( + underlyingAsset, + iconSymbol + ); // Disable the asset to prevent it from being supplied if supply cap has been reached const { supplyCap: supplyCapUsage, debtCeiling } = useAssetCaps(); @@ -239,11 +254,19 @@ export const SupplyAssetsListItemDesktop = ({ + {aTokenIconGenerator}