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
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
119 changes: 119 additions & 0 deletions src/hooks/pool/usePoolsMerits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* 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;

// `markets()` returns every market on the chain (Core, Lido,
// EtherFi, Horizon, …). Keep only the one this query is keyed on,
// otherwise identical underlyings across pools would get merged
// and `useUserYield` would credit incentives from the wrong pool.
const targetPool = marketData.addresses.LENDING_POOL?.toLowerCase();
const scopedMarkets = targetPool
? response.value.filter((m) => m.address?.toLowerCase() === targetPool)
: response.value;

const result: MeritAprByUnderlying = {};
for (const sdkMarket of scopedMarkets) {
const allReserves = [
...(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