Skip to content

Commit

Permalink
feat: Supported Zap Vaults [WEB-1424] (#276)
Browse files Browse the repository at this point in the history
* feat: Add `zapInWith` and `zapOutWith` to `VaultMetadata`

* feat: Function to fetch vault token market data

* feat: Merge new zapper props with current metadata

* fix: Remove focused test

* refactor: Use `getAddress`

* refactor: Return only the supported zapper addresses

* refactor: Move `mergeZapperPropsWithAddressables` to its own file

* refactor: Create the `Addressable` type

* refactor: Fix jsdoc

* refactor: Rename spec to addressable
  • Loading branch information
karelianpie authored and jstashh committed May 31, 2022
1 parent 85c76ef commit 1ebfbf5
Show file tree
Hide file tree
Showing 12 changed files with 291 additions and 10 deletions.
1 change: 1 addition & 0 deletions src/interfaces/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./mergeZapperPropsWithAddressables";
58 changes: 58 additions & 0 deletions src/interfaces/helpers/mergeZapperPropsWithAddressables.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { createMockTokenMarketData, createMockVaultMetadata } from "../../test-utils/factories";
import { mergeZapperPropsWithAddressables } from "./mergeZapperPropsWithAddressables";

describe("mergeZapperPropsWithAddressables", () => {
it("should set the zapper properties on an addressable", async () => {
const vaultMetadataMock = {
zappable: createMockVaultMetadata({
displayName: "Zappable",
address: "0x16de59092dae5ccf4a1e6439d611fd0653f0bd01", // not checksummed
}),
notZappable: createMockVaultMetadata({
displayName: "Not Zappable",
address: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
}),
};

const vaultTokenMarketDataMock = {
zappable: createMockTokenMarketData({
label: "Zappable",
address: "0x16de59092dAE5CcF4A1E6439D611fd0653f0Bd01", // checksummed
}),
notInVaults: createMockTokenMarketData({
label: "Not in Vaults",
address: "0xd6aD7a6750A7593E092a9B218d66C0A814a3436e",
}),
random: createMockTokenMarketData({ label: "Random", address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" }),
};

const actual = mergeZapperPropsWithAddressables(
[vaultMetadataMock.zappable, vaultMetadataMock.notZappable],
[
vaultTokenMarketDataMock.zappable.address,
vaultTokenMarketDataMock.notInVaults.address,
vaultTokenMarketDataMock.random.address,
]
);

expect(actual.length).toEqual(2);
expect(actual).toEqual(
expect.arrayContaining([
{
...vaultMetadataMock.zappable,
allowZapIn: true,
allowZapOut: true,
zapInWith: "zapperZapIn",
zapOutWith: "zapperZapOut",
},
{
...vaultMetadataMock.notZappable,
allowZapIn: false,
allowZapOut: false,
zapInWith: undefined,
zapOutWith: undefined,
},
])
);
});
});
33 changes: 33 additions & 0 deletions src/interfaces/helpers/mergeZapperPropsWithAddressables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { getAddress } from "@ethersproject/address";

import { Address, Addressable } from "../../types";

/**
* Helper function to set the zapper properties on an Addressable
* @param addressables an array of objects with an address prop
* @param supportedVaultAddresses the supported vault addresses
* @returns the updated metadata
*/
export function mergeZapperPropsWithAddressables<T extends Addressable>(
addressables: T[],
supportedVaultAddresses: Address[]
): T[] {
const supportedVaultAddressesSet = new Set(supportedVaultAddresses);

return addressables.map((addressable) => {
try {
const address = getAddress(addressable.address);
const isZappable = supportedVaultAddressesSet.has(address);

return {
...addressable,
allowZapIn: isZappable,
allowZapOut: isZappable,
zapInWith: isZappable ? "zapperZapIn" : undefined,
zapOutWith: isZappable ? "zapperZapOut" : undefined,
};
} catch (error) {
return addressable;
}
});
}
12 changes: 9 additions & 3 deletions src/interfaces/vault.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
createMockEarningsUserData,
createMockToken,
createMockTokenBalance,
createMockVaultMetadata,
} from "../test-utils/factories";

const earningsAccountAssetsDataMock = jest.fn();
Expand All @@ -45,6 +46,7 @@ const zapperZapInMock = jest.fn().mockResolvedValue({
gas: "100",
gasPrice: "100",
});
const supportedVaultAddressesMock = jest.fn();
const helperTokenBalancesMock = jest.fn();
const helperTokensMock: jest.Mock<Promise<ERC20[]>> = jest.fn();
const lensAdaptersVaultsV2PositionsOfMock = jest.fn();
Expand Down Expand Up @@ -109,6 +111,7 @@ jest.mock("../yearn", () => ({
zapper: {
zapOut: zapperZapOutMock,
zapIn: zapperZapInMock,
supportedVaultAddresses: supportedVaultAddressesMock,
},
transaction: {
sendTransaction: sendTransactionUsingServiceMock,
Expand Down Expand Up @@ -365,7 +368,8 @@ describe("VaultInterface", () => {
describe("when the fetcher tokens are not cached", () => {
beforeEach(() => {
cachedFetcherFetchMock.mockResolvedValue(undefined);
metaVaultsMock.mockResolvedValue([]);
metaVaultsMock.mockResolvedValue([createMockVaultMetadata({ address: "0x001" })]);
supportedVaultAddressesMock.mockResolvedValue([]);
});

describe("vaultMetadataOverrides", () => {
Expand All @@ -385,10 +389,11 @@ describe("VaultInterface", () => {
});

describe("when is not provided", () => {
it("should call meta vaults", async () => {
it("should get the vault's metadata", async () => {
await vaultInterface.getDynamic([]);

expect(metaVaultsMock).toHaveBeenCalledTimes(1);
expect(supportedVaultAddressesMock).toHaveBeenCalledTimes(1);
});
});
});
Expand Down Expand Up @@ -453,7 +458,7 @@ describe("VaultInterface", () => {
displayIcon: {
"0x001": "0x001.png",
},
displayName: "ALIAS_TOKEN_SYMBOL",
displayName: "Vault Metadata",
defaultDisplayToken: assetsDynamic.tokenId,
},
},
Expand Down Expand Up @@ -674,6 +679,7 @@ describe("VaultInterface", () => {
{
...tokenMock,
icon: "token-mock-icon.png",
symbol: "DEAD",
metadata: {
address: "0x001",
decimals: "18",
Expand Down
12 changes: 10 additions & 2 deletions src/interfaces/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { CallOverrides, Contract } from "@ethersproject/contracts";
import { TransactionRequest, TransactionResponse } from "@ethersproject/providers";

import { CachedFetcher } from "../cache";
import { ChainId } from "../chain";
import { ChainId, isEthereum } from "../chain";
import { ContractAddressId, ServiceInterface } from "../common";
import { chunkArray, EthAddress, isNativeToken, WethAddress } from "../helpers";
import { PickleJars } from "../services/partners/pickle";
Expand All @@ -27,6 +27,7 @@ import {
ZapProtocol,
} from "../types";
import { Position, Vault } from "../types";
import { mergeZapperPropsWithAddressables } from "./helpers";

const VaultAbi = ["function deposit(uint256 amount) public", "function withdraw(uint256 amount) public"];

Expand Down Expand Up @@ -144,13 +145,18 @@ export class VaultInterface<T extends ChainId> extends ServiceInterface<T> {
return addresses ? cached.filter((vault) => addresses.includes(vault.address)) : cached;
}

const metadataOverrides = vaultMetadataOverrides
let metadataOverrides = vaultMetadataOverrides
? vaultMetadataOverrides
: await this.yearn.services.meta.vaults().catch((error) => {
console.error(error);
return Promise.resolve([]);
});

if (isEthereum(this.chainId)) {
const vaultTokenMarketData = await this.yearn.services.zapper.supportedVaultAddresses();
metadataOverrides = mergeZapperPropsWithAddressables(metadataOverrides, vaultTokenMarketData);
}

const adapters = Object.values(this.yearn.services.lens.adapters.vaults);
return await Promise.all(
adapters.map(async (adapter) => {
Expand Down Expand Up @@ -655,6 +661,8 @@ export class VaultInterface<T extends ChainId> extends ServiceInterface<T> {
dynamic.metadata.withdrawalsDisabled = overrides.withdrawalsDisabled;
dynamic.metadata.allowZapIn = overrides.allowZapIn;
dynamic.metadata.allowZapOut = overrides.allowZapOut;
dynamic.metadata.zapInWith = overrides.zapInWith;
dynamic.metadata.zapOutWith = overrides.zapOutWith;
dynamic.metadata.migrationContract = overrides.migrationContract;
dynamic.metadata.migrationTargetVault = overrides.migrationTargetVault;
dynamic.metadata.vaultNameOverride = overrides.vaultNameOverride;
Expand Down
53 changes: 50 additions & 3 deletions src/services/zapper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,27 @@ import { getAddress } from "@ethersproject/address";

import { ChainId, Context, ZapperService } from "..";
import { Chains } from "../chain";
import { createMockZapperToken } from "../test-utils/factories";
import { createMockTokenMarketData, createMockZapperToken } from "../test-utils/factories";

const fetchSpy = jest.spyOn(global, "fetch");

const getAddressMock = jest.fn();

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

jest.mock("@ethersproject/address", () => ({
getAddress: jest.fn(() => getAddressMock()),
}));

describe("ZapperService", () => {
afterEach(() => {
jest.clearAllMocks();
});

let zapperServiceService: ZapperService;

([1, 1337, 250, 42161] as ChainId[]).forEach((chainId) =>
Expand Down Expand Up @@ -49,7 +55,7 @@ describe("ZapperService", () => {
})
) as jest.Mock
);
getAddressMock.mockReturnValue("0x001");
getAddressMock.mockReturnValueOnce("0x001");

const actualSupportedTokens = await zapperServiceService.supportedTokens();

Expand All @@ -73,4 +79,45 @@ describe("ZapperService", () => {
})
);
});

describe("supportedVaultAddresses", () => {
([1, 1337] as ChainId[]).forEach((chainId) =>
describe(`when chainId is ${chainId}`, () => {
beforeEach(() => {
zapperServiceService = new ZapperService(chainId, new Context({}));
});

it("should return the zapper supported vault addresses without `null`s", async () => {
const mockTokenMarketData = createMockTokenMarketData();
getAddressMock.mockReturnValueOnce(mockTokenMarketData.address);
fetchSpy.mockImplementation(
jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve([mockTokenMarketData, { address: null }]),
status: 200,
})
) as jest.Mock
);

const actual = await zapperServiceService.supportedVaultAddresses();

expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledWith(
"https://api.zapper.fi/v1/protocols/yearn/token-market-data?network=ethereum&type=vault&api_key=ZAPPER_API_KEY"
);
expect(actual).toEqual([mockTokenMarketData.address]);
});
})
);

([250, 42161] as ChainId[]).forEach((chainId) => {
it(`should throw when chainId is ${chainId}`, () => {
zapperServiceService = new ZapperService(chainId, new Context({}));

expect(async () => {
await zapperServiceService.supportedVaultAddresses();
}).rejects.toThrow(`Only Ethereum is supported for token market data, got ${chainId}`);
});
});
});
});
30 changes: 28 additions & 2 deletions src/services/zapper.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { getAddress } from "@ethersproject/address";

import { Chains } from "../chain";
import { Chains, isEthereum } from "../chain";
import { Service } from "../common";
import { EthAddress, handleHttpError, usdc, ZeroAddress } from "../helpers";
import { Address, Balance, BalancesMap, Integer, Token, ZapperToken } from "../types";
import { Address, Balance, BalancesMap, Integer, SdkError, Token, ZapperToken } from "../types";
import {
GasPrice,
VaultTokenMarketData,
ZapApprovalStateOutput,
ZapApprovalTransactionOutput,
ZapOutput,
Expand Down Expand Up @@ -104,6 +105,31 @@ export class ZapperService extends Service {
return balances;
}

/**
* Fetches vault token market data for the Yearn application
* @returns list of zapper supported vault addresses
*/
async supportedVaultAddresses(): Promise<Address[]> {
if (!isEthereum(this.chainId)) {
throw new SdkError(`Only Ethereum is supported for token market data, got ${this.chainId}`);
}

const url = "https://api.zapper.fi/v1/protocols/yearn/token-market-data";
const params = new URLSearchParams({
network: "ethereum",
type: "vault",
api_key: this.ctx.zapper,
});

const vaultTokenMarketData = await fetch(`${url}?${params}`)
.then(handleHttpError)
.then((res) => res.json());

return vaultTokenMarketData
.map((vaultTokenMarketData: VaultTokenMarketData) => getAddress(vaultTokenMarketData.address))
.filter((address: string) => !!address);
}

/**
* Fetch up to date gas prices in gwei
* @returns gas prices
Expand Down
1 change: 1 addition & 0 deletions src/test-utils/factories/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from "./earningsAssetData.factory";
export * from "./earningsUserData.factory";
export * from "./token.factory";
export * from "./tokenBalance.factory";
export * from "./tokenMarketData.factory";
export * from "./tokenMetadata.factory";
export * from "./vaultMetadata.factory";
export * from "./zapperToken.factory";
39 changes: 39 additions & 0 deletions src/test-utils/factories/tokenMarketData.factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { VaultTokenMarketData } from "../../types";

const DEFAULT_TOKEN_MARKET_DATA: VaultTokenMarketData = {
type: "vault",
category: "deposit",
network: "ethereum",
address: "0x16de59092dae5ccf4a1e6439d611fd0653f0bd01",
symbol: "yDAI",
label: "yDAI",
img: "https://storage.googleapis.com/zapper-fi-assets/apps/yearn.png",
decimals: 18,
price: 1.126570223844831,
pricePerShare: 1.126570223844831,
liquidity: 6899652.482441678,
supply: 6124476.163495696,
appId: "yearn",
isBlocked: true,
tokens: [
{
type: "base",
network: "ethereum",
address: "0x6b175474e89094c44da98b954eedeac495271d0f",
decimals: 18,
symbol: "DAI",
price: 1,
reserve: 6899652.482441678,
tokenImageUrl:
"https://storage.googleapis.com/zapper-fi-assets/tokens/ethereum/0x6b175474e89094c44da98b954eedeac495271d0f.png",
},
],
appName: "Yearn",
appImageUrl: "https://storage.googleapis.com/zapper-fi-assets/apps/yearn.png",
protcolDisplay: "Yearn",
};

export const createMockTokenMarketData = (overwrites: Partial<VaultTokenMarketData> = {}): VaultTokenMarketData => ({
...DEFAULT_TOKEN_MARKET_DATA,
...overwrites,
});
5 changes: 5 additions & 0 deletions src/types/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export class SdkError extends CustomError {
*/
export type Address = string;

/**
* Type for anything that has an address property.
*/
export type Addressable = { address: Address };

/**
* Type alias for a stringified big number. SDK tries to be bignumber-lib
* agnostic so integer values are returned as strings.
Expand Down
Loading

0 comments on commit 1ebfbf5

Please sign in to comment.