Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add ability to fetch strategy harvest info #291

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
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
135 changes: 135 additions & 0 deletions src/interfaces/strategy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { BigNumber } from "@ethersproject/bignumber";

import { ChainId } from "../chain";
import { Context } from "../context";
import { Yearn } from "../yearn";
import { StrategyInterface } from "./strategy";

const queryFilterMock = jest.fn();
const getPriceUsdcMock = jest.fn();
const decimalsMock = jest.fn();
const estimatedTotalAssetsMock = jest.fn();

jest.mock("@ethersproject/contracts", () => ({
Contract: jest.fn().mockImplementation(() => ({
filters: {
Harvested: jest.fn(),
},
queryFilter: queryFilterMock,
want: jest.fn(),
decimals: decimalsMock,
estimatedTotalAssets: estimatedTotalAssetsMock,
})),
}));

jest.mock("../context", () => ({
Context: jest.fn().mockImplementation(() => ({
provider: {
read: {},
},
})),
}));

jest.mock("../yearn", () => ({
Yearn: jest.fn().mockImplementation(() => ({
services: {
oracle: {
getPriceUsdc: getPriceUsdcMock,
},
},
})),
}));

describe("StrategyInterface", () => {
let mockedYearn: Yearn<ChainId>;
let strategyInterface: StrategyInterface<1>;

beforeEach(() => {
mockedYearn = new (Yearn as jest.Mock<Yearn<ChainId>>)();
strategyInterface = new StrategyInterface(mockedYearn, 1, new Context({}));
estimatedTotalAssetsMock.mockReturnValue(BigNumber.from(0));
});

afterEach(() => {
jest.clearAllMocks();
});

describe("get harvests", () => {
beforeEach(() => {
decimalsMock.mockReturnValue(BigNumber.from(18));
getPriceUsdcMock.mockReturnValue(Promise.resolve("1"));
});

it("cacluates gain in usdc correctly", async () => {
const gains = 42;
const price = 2;
const harvestEvent = {
args: {
profit: BigNumber.from(gains).mul(BigNumber.from(10).pow(18)),
},
getBlock: () => {
return Promise.resolve({ timestamp: 123 });
},
};

getPriceUsdcMock.mockReturnValue(Promise.resolve(`${price}000000`));
queryFilterMock.mockReturnValue([harvestEvent]);

const result = await strategyInterface.getHarvests({ strategyAddress: "" });
expect(result[0].gainUsdc.toString()).toEqual(`${gains * price}000000`);
});
});

it("orders harvests by newest first", async () => {
const olderHarvestTimestamp = 1653480070;
const newerHarvestTimestamp = olderHarvestTimestamp + 7 * 24 * 60 * 60;
const harvestEvents = [
{
getBlock: () => {
return Promise.resolve({ timestamp: olderHarvestTimestamp });
},
},
{
getBlock: () => {
return Promise.resolve({ timestamp: newerHarvestTimestamp });
},
},
];

queryFilterMock.mockReturnValue(harvestEvents);

const result = await strategyInterface.getHarvests({ strategyAddress: "" });
expect(result[0].time.getTime()).toBeGreaterThan(result[1].time.getTime());
});

it("fills in apr", async () => {
const olderHarvestTimestamp = 1653480070;
const newerHarvestTimestamp = olderHarvestTimestamp + 7 * 24 * 60 * 60;
estimatedTotalAssetsMock.mockReturnValue(BigNumber.from(1).mul(BigNumber.from(10).pow(18)));
const harvestEvents = [
{
args: {
profit: BigNumber.from(0),
},
getBlock: () => {
return Promise.resolve({ timestamp: olderHarvestTimestamp });
},
},
{
args: {
profit: BigNumber.from(5).mul(BigNumber.from(10).pow(18)),
},
getBlock: () => {
return Promise.resolve({ timestamp: newerHarvestTimestamp });
},
},
];

getPriceUsdcMock.mockReturnValue(Promise.resolve(`1000000`)); // 1 usdc
queryFilterMock.mockReturnValue(harvestEvents);

const result = await strategyInterface.getHarvests({ strategyAddress: "" });
expect(result[0].apr).toEqual(260.89285714285717);
expect(result[1].apr).toBeUndefined();
});
});
116 changes: 114 additions & 2 deletions src/interfaces/strategy.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { BigNumber } from "@ethersproject/bignumber";
import { Contract } from "@ethersproject/contracts";
import { BlockTag } from "@ethersproject/providers";
import { BigNumber as BigNumberJs } from "bignumber.js";
import fetch from "cross-fetch";

import { CachedFetcher } from "../cache";
import { ChainId } from "../chain";
import { ServiceInterface } from "../common";
import { Address, StrategiesMetadata, StrategyMetadata } from "../types";
import { VaultStrategiesMetadata } from "../types/strategy";
import { HarvestData, VaultStrategiesMetadata } from "../types/strategy";
import { getLocalizedString } from "../utils/localization";

interface VaultData {
Expand All @@ -26,7 +28,13 @@ const VaultAbi = [
"event StrategyAdded(address indexed strategy, uint256 debtRatio, uint256 minDebtPerHarvest, uint256 maxDebtPerHarvest, uint256 performanceFee)",
];

const TokenAbi = ["function symbol() view returns (string)"];
const TokenAbi = ["function symbol() view returns (string)", "function decimals() view returns (uint8)"];

const StrategyAbi = [
"event Harvested(uint256 profit, uint256 loss, uint256 debtPayment, uint256 debtOutstanding)",
"function want() view returns (address)",
"function estimatedTotalAssets() view returns (uint256)",
];

export class StrategyInterface<T extends ChainId> extends ServiceInterface<T> {
private cachedFetcher = new CachedFetcher<VaultStrategiesMetadata[]>(
Expand All @@ -35,6 +43,110 @@ export class StrategyInterface<T extends ChainId> extends ServiceInterface<T> {
this.chainId
);

/**
* Get information about the harvests of a strategy, based on `Harvested(uint256 profit, uint256 loss, uint256 debtPayment, uint256 debtOutstanding)`
* events that are emitted when the strategy is harvested
* @param strategyAddress the address of the strategy
* @param fromBlock optional block to query harvest events from, if omitted then with no limit
* @param toBlock optional block to query harvest events to, if omitted then to the most recent block
* @returns `HarvestData` which includes information such as the gain of the harvest in USDC, the time of the harvest, and the apr since the previous harvest
*/
async getHarvests({
strategyAddress,
fromBlock,
toBlock,
}: {
strategyAddress: Address;
fromBlock?: BlockTag;
toBlock?: BlockTag;
}): Promise<HarvestData[]> {
const contract = new Contract(strategyAddress, StrategyAbi, this.ctx.provider.read);
const filter = contract.filters.Harvested();
const harvestEvents = await contract.queryFilter(filter, fromBlock, toBlock);

// order the harvest events by newest first
harvestEvents.reverse();

// todo - add these calls into a new function in the strategies helper function
const want: Address = await contract.want();
const wantContract = new Contract(want, TokenAbi, this.ctx.provider.read);
const [decimals, usdcPrice] = await Promise.all([
wantContract.decimals() as Promise<BigNumber>,
this.yearn.services.oracle.getPriceUsdc(want).then((res) => BigNumber.from(res)),
]);

const harvestPromises = harvestEvents.map(async (event) => {
const gain: BigNumber = event.args?.profit ?? BigNumber.from(0);
const loss: BigNumber = event.args?.loss ?? BigNumber.from(0);
const gainUsdc = gain.mul(usdcPrice).div(BigNumber.from(10).pow(decimals));

const [timestamp, estimatedTotalAssets] = await Promise.all([
event.getBlock().then((block) => block.timestamp),
contract.estimatedTotalAssets({ blockTag: event.blockNumber }),
]);

return {
transactionId: event.transactionHash,
gain,
gainUsdc,
loss,
time: new Date(timestamp * 1000),
estimatedTotalAssets,
apr: undefined, // will be filled in using the `populateHarvestAprs` function
};
});

const harvests = await Promise.all(harvestPromises);
return this.populateHarvestAprs(harvests);
}

/**
* Populates the `apr` field of the `HarvestData` object by referencing the time since the previous harvest
* @param harvests an array of `HarvestData` objects to populate, assumes ordered by newest first
* @returns the array of `HarvestData` with populated `apr` fields
*/
private populateHarvestAprs(harvests: HarvestData[]): HarvestData[] {
// loop through the harvests, and calculate the apr by referencing the time since the previous harvest
harvests.forEach((harvest, index) => {
// if the gain is 0 then the apr is 0
if (harvest.gain === BigNumber.from(0)) {
harvests[index].apr = 0;
return;
} else if (index + 1 === harvests.length) {
// if this is the oldest harvest leave the apy as undefined since there is no previous harvest to compare it against
return;
}

const previousHarvest = harvests[index + 1];

// get the difference in days between this harvest and the one prior
const days = (harvest.time.getTime() - previousHarvest.time.getTime()) / 1000 / 60 / 60 / 24;

// need to use BigNumber.js since days could be less than 0, which Ether's BigNumber type does not support
const daysBigJs = new BigNumberJs(days);

if (previousHarvest.estimatedTotalAssets.toString() === "0") {
harvests[index].apr = 0;
return;
}

// need to use the assets at the block of the previous harvest, since that's what the current harvest's gains are based on
const estimatedTotalAssetsBigJs = new BigNumberJs(previousHarvest.estimatedTotalAssets.toString());

// apr = gain / estimated total assets / days since previous harvest * 365.25
const gainBigJs = new BigNumberJs(harvest.gain.toString());
const apr = gainBigJs
.div(estimatedTotalAssetsBigJs)
.div(daysBigJs)
.multipliedBy(new BigNumberJs(365.25))
.toNumber();

harvests[index].apr = apr;
});

return harvests;
}

async vaultsStrategiesMetadata(vaultAddresses?: Address[]): Promise<VaultStrategiesMetadata[]> {
const cached = await this.cachedFetcher.fetch();
if (cached) {
Expand Down
12 changes: 12 additions & 0 deletions src/types/strategy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
import { BigNumber } from "@ethersproject/bignumber";

import { Address } from "./common";
import { StrategyMetadata } from "./metadata";
export interface VaultStrategiesMetadata {
vaultAddress: Address;
strategiesMetadata: StrategyMetadata[];
}

export interface HarvestData {
transactionId: string;
gain: BigNumber;
gainUsdc: BigNumber;
loss: BigNumber;
time: Date;
estimatedTotalAssets: BigNumber;
apr?: number;
}