Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ yarn-debug.log*
yarn-error.log*

# local env files
.env
.env.local
.env.development.local
.env.test.local
Expand All @@ -45,7 +46,7 @@ package-lock.json
.yarn-cache

# TypeScript incremental build cache
tsconfig.tsbuildinfo
*.tsbuildinfo

# IDE specific
.idea
Expand Down
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ src/locales/
*.mermaid
*.log
*.lock
*.patch
.yarnrc
6 changes: 6 additions & 0 deletions .yarnrc
Original file line number Diff line number Diff line change
@@ -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
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -29,6 +30,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'",
Expand All @@ -50,6 +52,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",
Expand Down Expand Up @@ -99,6 +104,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"
Expand Down Expand Up @@ -140,6 +146,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"
},
Expand Down
14 changes: 14 additions & 0 deletions pages/_app.page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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)
);
Expand Down Expand Up @@ -164,6 +177,7 @@ export default function MyApp(props: MyAppProps) {
<GasStationProvider>
{getLayout(<Component {...pageProps} />)}
<SupplyModal />
<FunkitCheckout />
<WithdrawModal />
<BorrowModal />
<RepayModal />
Expand Down
31 changes: 31 additions & 0 deletions patches/@funkit+connect+9.18.0.patch
Original file line number Diff line number Diff line change
@@ -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";
142 changes: 142 additions & 0 deletions src/components/transactions/FunCheckout/FunkitCheckout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<FunkitProvider funkitConfig={funkitConfig} theme={aaveTheme} modalSize="medium">
<InnerCheckout />
</FunkitProvider>
);
}

export default FunkitCheckout;
102 changes: 102 additions & 0 deletions src/components/transactions/FunCheckout/funSupplyAssets.ts
Original file line number Diff line number Diff line change
@@ -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<string> = 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,
},
});
}
Loading
Loading