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
5 changes: 5 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
1 change: 1 addition & 0 deletions .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 Down
8 changes: 6 additions & 2 deletions 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 Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -166,4 +170,4 @@
"budgetPercentIncreaseRed": 20,
"showDetails": true
}
}
}
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
88 changes: 88 additions & 0 deletions src/components/transactions/FunCheckout/FunSupplyButton.tsx
Original file line number Diff line number Diff line change
@@ -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<ButtonProps, 'onClick' | 'children'> & {
/** 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 && (
<Box component="span" sx={{ position: 'absolute', width: 0, height: 0 }}>
{generator}
</Box>
)}
<Button
variant="contained"
{...buttonProps}
onClick={() =>
handleSupplyClick({
underlyingAsset,
name,
symbol,
aTokenBase64,
supplyAPY,
collateralEnabled,
})
}
>
{children ?? <Trans>Supply</Trans>}
</Button>
</>
);
}

export default FunSupplyButton;
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;
Loading