Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,4 @@ package-lock.json

# Sentry Config File
.env.sentry-build-plugin
tsconfig.tsbuildinfo
1 change: 1 addition & 0 deletions src/components/incentives/IncentivesCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface IncentivesCardProps {
symbol: string;
value: string | number;
incentives?: ReserveIncentiveResponse[];
/** aToken / vToken address (legacy; hook resolves underlying internally). */
address?: string;
variant?: 'main14' | 'main16' | 'secondary14';
symbolsVariant?: 'secondary14' | 'secondary16';
Expand Down
33 changes: 19 additions & 14 deletions src/components/incentives/MeritIncentivesTooltipContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,19 @@ interface CampaignConfig {
}

const isCeloAction = (action: MeritAction): boolean => {
return [
MeritAction.CELO_SUPPLY_CELO,
MeritAction.CELO_SUPPLY_USDT,
MeritAction.CELO_SUPPLY_USDC,
MeritAction.CELO_SUPPLY_WETH,
MeritAction.CELO_SUPPLY_MULTIPLE_BORROW_USDT,
MeritAction.CELO_BORROW_CELO,
MeritAction.CELO_BORROW_USDT,
MeritAction.CELO_BORROW_USDC,
MeritAction.CELO_BORROW_WETH,
].includes(action);
return (
[
MeritAction.CELO_SUPPLY_CELO,
MeritAction.CELO_SUPPLY_USDT,
MeritAction.CELO_SUPPLY_USDC,
MeritAction.CELO_SUPPLY_WETH,
MeritAction.CELO_SUPPLY_MULTIPLE_BORROW_USDT,
MeritAction.CELO_BORROW_CELO,
MeritAction.CELO_BORROW_USDT,
MeritAction.CELO_BORROW_USDC,
MeritAction.CELO_BORROW_WETH,
] as string[]
).includes(action);
};

const selfCampaignConfig: Map<MeritAction, { limit: string; token: string }> = new Map([
Expand Down Expand Up @@ -122,12 +124,15 @@ export const MeritIncentivesTooltipContent = ({
};
const meritIncentivesFormatted = getSymbolMap(meritIncentives);
const isCombinedMeritIncentives: boolean = meritIncentives.activeActions.length > 1;
const campaignConfig = getCampaignConfig(meritIncentives.action);
const selfConfig = selfCampaignConfig.get(meritIncentives.action);
// `action` is now optional (backend-driven). Fall back to an empty string
// so the switch/lookup helpers match their STANDARD branch.
const primaryAction = meritIncentives.action ?? '';
const campaignConfig = getCampaignConfig(primaryAction);
const selfConfig = selfCampaignConfig.get(primaryAction);

const remainingCustomMessage = getRemainingMessagesWhenCombined(
meritIncentives.activeActions,
meritIncentives.action,
primaryAction,
isCombinedMeritIncentives,
meritIncentives.actionMessages
);
Expand Down
52 changes: 4 additions & 48 deletions src/components/incentives/MerklIncentivesTooltipContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,55 +185,11 @@ export const MerklIncentivesTooltipContent = ({
</Typography>
</Box>
</Row>
) : merklIncentives.rewardsTokensMappedApys &&
merklIncentives.rewardsTokensMappedApys.length > 1 ? (
<>
{merklIncentives.rewardsTokensMappedApys.map((reward, index) => {
const { tokenIconSymbol, symbol, aToken } = getSymbolMap({
rewardTokenSymbol: reward.token.symbol,
rewardTokenAddress: reward.token.address,
incentiveAPR: reward.apy.toString(),
});
return (
<Row
key={index}
height={32}
caption={
<Box
sx={{
display: 'flex',
alignItems: 'center',
mb: 0,
}}
>
<TokenIcon
symbol={tokenIconSymbol}
aToken={aToken}
sx={{ fontSize: '20px', mr: 1 }}
/>
<Typography variant={typographyVariant}>{symbol}</Typography>
<Typography variant={typographyVariant} sx={{ ml: 0.5 }}>
{merklIncentives.breakdown.isBorrow ? '(-)' : '(+)'}
</Typography>
</Box>
}
width="100%"
>
<Box sx={{ display: 'inline-flex', alignItems: 'center' }}>
<FormattedNumber
value={merklIncentives.breakdown.isBorrow ? -reward.apy : reward.apy}
percent
variant={typographyVariant}
/>
<Typography variant={typographyVariant} sx={{ ml: 1 }}>
<Trans>APY</Trans>
</Typography>
</Box>
</Row>
);
})}
</>
) : (
// Note: legacy multi-reward-token rendering (`rewardsTokensMappedApys`)
// is gone. The V3 backend returns one `MerklSupply/Borrow`
// variant per reserve per direction with a single `payoutToken`,
// so the single-row render below covers all live campaigns.
<Row
height={32}
caption={
Expand Down
9 changes: 8 additions & 1 deletion src/hooks/app-data-provider/useAppDataProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,14 @@ export const AppDataProvider: React.FC<PropsWithChildren> = ({ children }) => {

const marketAddress = currentMarketData.addresses.LENDING_POOL.toLowerCase();

const sdkMarket = data?.find((item) => item.address.toLowerCase() === marketAddress);
// react-query's structural sharing can replace our Market[] with a
// structurally-similar plain object on refetch when it encounters
// non-POJO values (e.g. bigint-ish strings wrapped by the SDK). Guard
// before calling Array.prototype methods.
const marketsList = Array.isArray(data) ? data : [];
const sdkMarket = marketsList.find(
(item) => item.address.toLowerCase() === marketAddress,
);

const totalBorrows = sdkMarket?.borrowReserves.reduce((acc, reserve) => {
const value = reserve.borrowInfo?.total?.usd ?? 0;
Expand Down
110 changes: 110 additions & 0 deletions src/hooks/pool/usePoolsMerits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* Per-market Merit APR lookup for the net-APY calculation in
* `useUserYield`.
*
* Reads directly from the SDK's `markets()` query (same react-query cache
* as `useAppDataProvider`'s `useMarketsData`), extracts each reserve's
* active `MeritSupply/Borrow/Conditional` incentive, and keys by
* underlying address. The backend already evaluates `userEligible` when
* the user address is passed, so we only credit APR for reserves the user
* is actually eligible for — same behaviour as the legacy aavechan
* per-user fetch.
*
* No new GraphQL query: the shared cache means calling this hook
* alongside the main AppDataProvider fetch is a cache hit.
*/
import { chainId as sdkChainId, evmAddress, OrderDirection } from '@aave/client';
import { markets } from '@aave/client/actions';
import { useQueries } from '@tanstack/react-query';
import { client } from 'pages/_app.page';
import { MarketDataType } from 'src/ui-config/marketsConfig';
import { queryKeysFactory } from 'src/ui-config/queries';

/**
* Map of `lowercase(underlyingAddress) -> {supplyApr, borrowApr}`. Backed
* by a plain Record because react-query's default `structuralSharing`
* deep-merges fetched data against the previous value, and `Map` instances
* don't round-trip through that merge — they come back as plain objects on
* refetch and `.get()` blows up at the consumer.
*/
export type MeritAprByUnderlying = Record<string, { supplyApr: number; borrowApr: number }>;

const EMPTY_MAP: MeritAprByUnderlying = Object.freeze({});

type Incentive = {
__typename?: string;
userEligible?: boolean | null;
extraSupplyApr?: { formatted: string } | null;
borrowAprDiscount?: { formatted: string } | null;
extraApr?: { formatted: string } | null;
};

const parseApr = (value?: { formatted: string } | null): number => {
if (!value) return 0;
const n = parseFloat(value.formatted);
return Number.isFinite(n) && n > 0 ? n : 0;
};

/**
* Per-market query that resolves the SDK's `markets()` response and builds
* a `Map<underlyingAddress, {supplyApr, borrowApr}>` of eligible Merit
* APRs for the user. Entries are only present when the user passes the
* backend's eligibility criteria for that reserve; missing keys mean "no
* Merit contribution for this position".
*/
export const usePoolsMerits = (
marketsData: MarketDataType[],
userAddress?: string | null,
) => {
const userAddr = userAddress ? evmAddress(userAddress) : undefined;

return useQueries({
queries: marketsData.map((marketData) => ({
queryKey: [
...queryKeysFactory.market(marketData),
...queryKeysFactory.user(userAddr ?? 'anonymous'),
],
enabled: !!client,
queryFn: async (): Promise<MeritAprByUnderlying> => {
const response = await markets(client, {
chainIds: [sdkChainId(marketData.chainId)],
user: userAddr,
suppliesOrderBy: { tokenName: OrderDirection.Asc },
borrowsOrderBy: { tokenName: OrderDirection.Asc },
});
if (response.isErr()) throw response.error;

const result: MeritAprByUnderlying = {};
for (const sdkMarket of response.value) {
const allReserves = [
Comment thread
mgrabina marked this conversation as resolved.
Outdated
...(sdkMarket.supplyReserves ?? []),
...(sdkMarket.borrowReserves ?? []),
];
for (const r of allReserves) {
const underlying = r.underlyingToken.address.toLowerCase();
const existing = result[underlying] ?? { supplyApr: 0, borrowApr: 0 };
const incentives: Incentive[] = (r.incentives ?? []) as Incentive[];
for (const inc of incentives) {
if (!inc.userEligible) continue;
if (inc.__typename === 'MeritSupplyIncentive') {
existing.supplyApr += parseApr(inc.extraSupplyApr);
} else if (inc.__typename === 'MeritBorrowIncentive') {
existing.borrowApr += parseApr(inc.borrowAprDiscount);
} else if (inc.__typename === 'MeritBorrowAndSupplyIncentiveCondition') {
// Conditional reward: paid to both sides when the user
// holds the specified collateral + debt simultaneously.
const apr = parseApr(inc.extraApr);
existing.supplyApr += apr;
existing.borrowApr += apr;
}
}
result[underlying] = existing;
}
}
return result;
},
})),
});
};

export const emptyMeritMap = (): MeritAprByUnderlying => EMPTY_MAP;
80 changes: 27 additions & 53 deletions src/hooks/pool/useUserYield.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,20 @@
import { ProtocolAction } from '@aave/contract-helpers';
import { FormatUserSummaryAndIncentivesResponse } from '@aave/math-utils';
import { BigNumber } from 'bignumber.js';
import memoize from 'micro-memoize';
import { MarketDataType } from 'src/ui-config/marketsConfig';

import { getMeritData } from '../useMeritIncentives';
import { useUserMeritIncentives } from '../useUserMeritIncentives';
import {
emptyMeritMap,
MeritAprByUnderlying,
usePoolsMerits,
} from './usePoolsMerits';
import {
FormattedReservesAndIncentives,
usePoolsFormattedReserves,
} from './usePoolFormattedReserves';
import { useUserSummariesAndIncentives } from './useUserSummaryAndIncentives';
import { combineQueries, SimplifiedUseQueryResult } from './utils';

type UserMeritIncentivesData = {
currentAPR: {
actionsAPY: Record<string, number>;
};
} | null;

export interface UserYield {
earnedAPY: number;
debtAPY: number;
Expand All @@ -29,8 +25,7 @@ const formatUserYield = memoize(
(
formattedPoolReserves: FormattedReservesAndIncentives[],
user: FormatUserSummaryAndIncentivesResponse,
userMeritIncentives?: UserMeritIncentivesData,
marketTitle?: string
meritByUnderlying: MeritAprByUnderlying,
) => {
const proportions = user.userReservesData.reduce(
(acc, value) => {
Expand All @@ -39,6 +34,7 @@ const formatUserYield = memoize(
);

if (reserve) {
const meritEntry = meritByUnderlying[reserve.underlyingAsset.toLowerCase()];
if (value.underlyingBalanceUSD !== '0') {
acc.positiveProportion = acc.positiveProportion.plus(
new BigNumber(reserve.supplyAPY).multipliedBy(value.underlyingBalanceUSD)
Expand All @@ -50,22 +46,15 @@ const formatUserYield = memoize(
);
});
}

// Add merit incentives for supply positions
if (userMeritIncentives?.currentAPR?.actionsAPY) {
const meritData = getMeritData(marketTitle || '', reserve.symbol);
if (meritData) {
meritData.forEach((merit) => {
if (merit.protocolAction === ProtocolAction.supply) {
const meritAPY = userMeritIncentives.currentAPR.actionsAPY[merit.action];
if (meritAPY) {
acc.positiveProportion = acc.positiveProportion.plus(
new BigNumber(meritAPY / 100).multipliedBy(value.underlyingBalanceUSD)
);
}
}
});
}
// Merit supply-side APR — backend already filtered by user
// eligibility (only credits when the user passes the criteria
// rules for the program).
if (meritEntry && meritEntry.supplyApr > 0) {
acc.positiveProportion = acc.positiveProportion.plus(
new BigNumber(meritEntry.supplyApr / 100).multipliedBy(
value.underlyingBalanceUSD
)
);
}
}
if (value.variableBorrowsUSD !== '0') {
Expand All @@ -79,23 +68,14 @@ const formatUserYield = memoize(
);
});
}

// Add merit incentives for borrow positions (reduces borrowing cost)
if (userMeritIncentives?.currentAPR?.actionsAPY) {
const meritData = getMeritData(marketTitle || '', reserve.symbol);
if (meritData) {
meritData.forEach((merit) => {
if (merit.protocolAction === ProtocolAction.borrow) {
const meritAPY = userMeritIncentives.currentAPR.actionsAPY[merit.action];
if (meritAPY) {
// For borrow positions, merit incentives reduce the effective borrow cost
acc.positiveProportion = acc.positiveProportion.plus(
new BigNumber(meritAPY / 100).multipliedBy(value.variableBorrowsUSD)
);
}
}
});
}
// Merit borrow-side APR (negative on the debt cost, hence
// added to the positive proportion to offset borrow interest).
if (meritEntry && meritEntry.borrowApr > 0) {
acc.positiveProportion = acc.positiveProportion.plus(
new BigNumber(meritEntry.borrowApr / 100).multipliedBy(
value.variableBorrowsUSD
)
);
}
}
} else {
Expand Down Expand Up @@ -132,21 +112,15 @@ export const useUserYields = (
): SimplifiedUseQueryResult<UserYield>[] => {
const poolsFormattedReservesQuery = usePoolsFormattedReserves(marketsData);
const userSummaryQuery = useUserSummariesAndIncentives(marketsData);
const userMeritIncentivesQuery = useUserMeritIncentives(userAddress);
const poolsMeritsQueries = usePoolsMerits(marketsData, userAddress);

return poolsFormattedReservesQuery.map((elem, index) => {
const meritMap = poolsMeritsQueries[index]?.data ?? emptyMeritMap();
const selector = (
formattedPoolReserves: FormattedReservesAndIncentives[],
user: FormatUserSummaryAndIncentivesResponse
) => {
// Get merit incentives data separately
const meritIncentives = userMeritIncentivesQuery.data;
return formatUserYield(
formattedPoolReserves,
user,
meritIncentives,
marketsData[index].market
);
return formatUserYield(formattedPoolReserves, user, meritMap);
};

return combineQueries([elem, userSummaryQuery[index]] as const, selector);
Expand Down
Loading
Loading