Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 .changeset/olive-pandas-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@venusprotocol/evm": minor
---

display the per-user reward APY of collateral-gated Merkl borrow campaigns
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import BigNumber from 'bignumber.js';

import { assetData } from '__mocks__/models/asset';
import { isolatedPool, legacyCorePool } from '__mocks__/models/pools';
import { xvs } from '__mocks__/models/tokens';
import { vUsdc, vUsdtCorePool, vXvs } from '__mocks__/models/vTokens';
import type { Asset, MerklDistribution, Pool, VToken } from 'types';

import { appendMerklCollateralGates } from '..';

const buildGatedDistribution = (): MerklDistribution => ({
type: 'merkl',
token: xvs,
apyPercentage: new BigNumber(20),
dailyDistributedTokens: new BigNumber(0),
isActive: true,
rewardDetails: {
appName: 'Merkl',
claimUrl: 'https://app.merkl.xyz/',
marketAddress: vUsdtCorePool.address,
merklCampaignIdentifier: '0xfake',
description: 'Merkl campaign',
tags: [],
aprPercentage: 20,
participatingCollateralAddresses: [vXvs.address],
eligibleBorrowMarketAddresses: [vUsdtCorePool.address, vUsdc.address],
},
});

const buildAsset = ({ vToken, ...overrides }: { vToken: VToken } & Partial<Asset>): Asset => ({
...assetData[0],
vToken,
isCollateralOfUser: false,
userSupplyBalanceCents: new BigNumber(0),
userBorrowBalanceCents: new BigNumber(0),
supplyTokenDistributions: [],
borrowTokenDistributions: [],
...overrides,
});

// $1000 of collateral at a 60% collateral factor, against $500 of USDT and $500 of USDC borrows
const buildPools = ({
collateralCents = new BigNumber(100000),
isCollateralOfUser = true,
usdtBorrowCents = new BigNumber(50000),
usdcBorrowCents = new BigNumber(50000),
}: {
collateralCents?: BigNumber;
isCollateralOfUser?: boolean;
usdtBorrowCents?: BigNumber;
usdcBorrowCents?: BigNumber;
} = {}): Pool[] => [
{
...legacyCorePool,
assets: [
buildAsset({
vToken: vXvs,
isCollateralOfUser,
userSupplyBalanceCents: collateralCents,
userCollateralFactor: 0.6,
}),
buildAsset({
vToken: vUsdtCorePool,
userBorrowBalanceCents: usdtBorrowCents,
borrowTokenDistributions: [buildGatedDistribution()],
}),
buildAsset({
vToken: vUsdc,
userBorrowBalanceCents: usdcBorrowCents,
borrowTokenDistributions: [buildGatedDistribution()],
}),
],
},
];

const getGate = (pools: Pool[], assetIndex: number) => {
const distribution = pools[0].assets[assetIndex].borrowTokenDistributions[0];
return distribution.type === 'merkl' ? distribution : undefined;
};

describe('appendMerklCollateralGates', () => {
it('scales the campaign APR down by the share of the loan the collateral covers', () => {
const pools = buildPools();

appendMerklCollateralGates({ pools });

const distribution = getGate(pools, 1);
expect(distribution?.collateralGate?.isUserEligible).toBe(true);
expect(distribution?.collateralGate?.maxApyPercentage.toFixed()).toBe('20');
// 20% * min($600, $1000) / $1000
expect(distribution?.apyPercentage.toFixed()).toBe('12');
});

it('applies the same reward APY to every eligible borrow market of the campaign', () => {
const pools = buildPools();

appendMerklCollateralGates({ pools });

expect(getGate(pools, 2)?.apyPercentage.toFixed()).toBe('12');
});

it('awards the full campaign APR when the collateral covers the whole loan', () => {
const pools = buildPools({ collateralCents: new BigNumber(1000000) });

appendMerklCollateralGates({ pools });

expect(getGate(pools, 1)?.apyPercentage.toFixed()).toBe('20');
});

it('marks the user as ineligible when they hold none of the participating collateral', () => {
const pools = buildPools({ collateralCents: new BigNumber(0) });

appendMerklCollateralGates({ pools });

const distribution = getGate(pools, 1);
expect(distribution?.collateralGate?.isUserEligible).toBe(false);
expect(distribution?.apyPercentage.toFixed()).toBe('0');
});

it('marks the user as ineligible when the participating collateral is not enabled', () => {
const pools = buildPools({ isCollateralOfUser: false });

appendMerklCollateralGates({ pools });

expect(getGate(pools, 1)?.collateralGate?.isUserEligible).toBe(false);
});

it('marks the user as ineligible when they borrow none of the eligible markets', () => {
const pools = buildPools({
usdtBorrowCents: new BigNumber(0),
usdcBorrowCents: new BigNumber(0),
});

appendMerklCollateralGates({ pools });

const distribution = getGate(pools, 1);
expect(distribution?.collateralGate?.isUserEligible).toBe(false);
expect(distribution?.apyPercentage.toFixed()).toBe('0');
});

it('leaves ungated Merkl campaigns untouched', () => {
const ungatedDistribution: MerklDistribution = {
...buildGatedDistribution(),
apyPercentage: new BigNumber(5),
rewardDetails: {
...buildGatedDistribution().rewardDetails,
participatingCollateralAddresses: [],
eligibleBorrowMarketAddresses: [],
},
};

const pools: Pool[] = [
{
...legacyCorePool,
assets: [
buildAsset({
vToken: vUsdtCorePool,
borrowTokenDistributions: [ungatedDistribution],
}),
],
},
];

appendMerklCollateralGates({ pools });

const distribution = getGate(pools, 0);
expect(distribution?.collateralGate).toBeUndefined();
expect(distribution?.apyPercentage.toFixed()).toBe('5');
});

it('ignores collateral held in another pool, which cannot back the borrow', () => {
const pools: Pool[] = [
{
...legacyCorePool,
assets: [
buildAsset({
vToken: vUsdtCorePool,
userBorrowBalanceCents: new BigNumber(50000),
borrowTokenDistributions: [buildGatedDistribution()],
}),
],
},
{
...isolatedPool,
assets: [
buildAsset({
vToken: vXvs,
isCollateralOfUser: true,
userSupplyBalanceCents: new BigNumber(100000),
userCollateralFactor: 0.6,
}),
],
},
];

appendMerklCollateralGates({ pools });

const distribution = getGate(pools, 0);
expect(distribution?.collateralGate?.isUserEligible).toBe(false);
expect(distribution?.apyPercentage.toFixed()).toBe('0');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import BigNumber from 'bignumber.js';

import type { Pool } from 'types';
import { areAddressesEqual } from 'utilities';

export const appendMerklCollateralGates = ({ pools }: { pools: Pool[] }) => {
// Collateral only backs borrows made within the same pool, so each pool is resolved on its own
for (const pool of pools) {
for (const asset of pool.assets) {
for (const distribution of asset.borrowTokenDistributions) {
if (distribution.type !== 'merkl') {
continue;
}

const {
aprPercentage = 0,
participatingCollateralAddresses = [],
eligibleBorrowMarketAddresses = [],
} = distribution.rewardDetails;

if (eligibleBorrowMarketAddresses.length === 0) {
continue;
}

const collateralCents = pool.assets.reduce((acc, collateralAsset) => {
const isParticipating = participatingCollateralAddresses.some(address =>
areAddressesEqual(address, collateralAsset.vToken.address),
);

return collateralAsset.isCollateralOfUser && isParticipating
? acc.plus(
collateralAsset.userSupplyBalanceCents.multipliedBy(
collateralAsset.userCollateralFactor,
),
)
: acc;
}, new BigNumber(0));

const eligibleLoanCents = pool.assets.reduce(
(acc, borrowAsset) =>
eligibleBorrowMarketAddresses.some(address =>
areAddressesEqual(address, borrowAsset.vToken.address),
)
? acc.plus(borrowAsset.userBorrowBalanceCents)
: acc,
new BigNumber(0),
);

const maxApyPercentage = new BigNumber(aprPercentage);
const isUserEligible =
collateralCents.isGreaterThan(0) && eligibleLoanCents.isGreaterThan(0);

distribution.collateralGate = { isUserEligible, maxApyPercentage };
distribution.apyPercentage = isUserEligible
? maxApyPercentage
.multipliedBy(BigNumber.minimum(collateralCents, eligibleLoanCents))
.dividedBy(eligibleLoanCents)
: new BigNumber(0);
}
}
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from 'libs/contracts';
import type { Asset, TokenBalance } from 'types';
import type { GetPoolsInput, GetPoolsOutput, PrimeApy, VTokenBalance } from '../../types';
import { appendMerklCollateralGates } from './appendMerklCollateralGates';
import { appendPrimeSimulationDistributions } from './appendPrimeSimulationDistributions';
import { formatOutput } from './formatOutput';
import { type ApiTokenMetadata, getApiPools } from './getApiPools';
Expand Down Expand Up @@ -214,6 +215,9 @@ export const getPools = async ({
vaiPriceMantissa: vaiPriceMantissaResult,
});

// Resolve per-user reward APYs of collateral-gated Merkl campaigns
appendMerklCollateralGates({ pools });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Simulations retain stale Merkl gates

When a transaction preview changes participating collateral, collateral status, or eligible debt, getSimulatedPool copies the Merkl gate and prorated APY computed for the current pool instead of recalculating them. The form therefore shows stale eligibility, reward APY, and total post-transaction APY—for example, withdrawing the qualifying collateral can leave the reward displayed as active.

Knowledge Base Used: Lending transaction flows

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed


// Add Prime simulations
// TODO: get Prime simulations from API
const xvs = tokens.find(token => token.symbol === 'XVS');
Expand Down
Loading
Loading