diff --git a/src/interfaces/helpers/index.ts b/src/interfaces/helpers/index.ts new file mode 100644 index 00000000..1b1e7862 --- /dev/null +++ b/src/interfaces/helpers/index.ts @@ -0,0 +1 @@ +export * from "./mergeZapperPropsWithAddressables"; diff --git a/src/interfaces/helpers/mergeZapperPropsWithAddressables.spec.ts b/src/interfaces/helpers/mergeZapperPropsWithAddressables.spec.ts new file mode 100644 index 00000000..3c039239 --- /dev/null +++ b/src/interfaces/helpers/mergeZapperPropsWithAddressables.spec.ts @@ -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, + }, + ]) + ); + }); +}); diff --git a/src/interfaces/helpers/mergeZapperPropsWithAddressables.ts b/src/interfaces/helpers/mergeZapperPropsWithAddressables.ts new file mode 100644 index 00000000..e4e4ee4f --- /dev/null +++ b/src/interfaces/helpers/mergeZapperPropsWithAddressables.ts @@ -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( + 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; + } + }); +} diff --git a/src/interfaces/vault.spec.ts b/src/interfaces/vault.spec.ts index 88f6d6f1..1b4e5a9f 100644 --- a/src/interfaces/vault.spec.ts +++ b/src/interfaces/vault.spec.ts @@ -30,6 +30,7 @@ import { createMockEarningsUserData, createMockToken, createMockTokenBalance, + createMockVaultMetadata, } from "../test-utils/factories"; const earningsAccountAssetsDataMock = jest.fn(); @@ -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> = jest.fn(); const lensAdaptersVaultsV2PositionsOfMock = jest.fn(); @@ -109,6 +111,7 @@ jest.mock("../yearn", () => ({ zapper: { zapOut: zapperZapOutMock, zapIn: zapperZapInMock, + supportedVaultAddresses: supportedVaultAddressesMock, }, transaction: { sendTransaction: sendTransactionUsingServiceMock, @@ -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", () => { @@ -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); }); }); }); @@ -453,7 +458,7 @@ describe("VaultInterface", () => { displayIcon: { "0x001": "0x001.png", }, - displayName: "ALIAS_TOKEN_SYMBOL", + displayName: "Vault Metadata", defaultDisplayToken: assetsDynamic.tokenId, }, }, @@ -674,6 +679,7 @@ describe("VaultInterface", () => { { ...tokenMock, icon: "token-mock-icon.png", + symbol: "DEAD", metadata: { address: "0x001", decimals: "18", diff --git a/src/interfaces/vault.ts b/src/interfaces/vault.ts index 8303ca34..5427481f 100644 --- a/src/interfaces/vault.ts +++ b/src/interfaces/vault.ts @@ -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"; @@ -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"]; @@ -144,13 +145,18 @@ export class VaultInterface extends ServiceInterface { 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) => { @@ -655,6 +661,8 @@ export class VaultInterface extends ServiceInterface { 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; diff --git a/src/services/zapper.spec.ts b/src/services/zapper.spec.ts index 7f7c2431..615e569b 100644 --- a/src/services/zapper.spec.ts +++ b/src/services/zapper.spec.ts @@ -2,14 +2,16 @@ 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", () => ({ @@ -17,6 +19,10 @@ jest.mock("@ethersproject/address", () => ({ })); describe("ZapperService", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + let zapperServiceService: ZapperService; ([1, 1337, 250, 42161] as ChainId[]).forEach((chainId) => @@ -49,7 +55,7 @@ describe("ZapperService", () => { }) ) as jest.Mock ); - getAddressMock.mockReturnValue("0x001"); + getAddressMock.mockReturnValueOnce("0x001"); const actualSupportedTokens = await zapperServiceService.supportedTokens(); @@ -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}`); + }); + }); + }); }); diff --git a/src/services/zapper.ts b/src/services/zapper.ts index 7ea6dace..7fd6dd4c 100644 --- a/src/services/zapper.ts +++ b/src/services/zapper.ts @@ -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, @@ -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 { + 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 diff --git a/src/test-utils/factories/index.ts b/src/test-utils/factories/index.ts index 14249ad9..7b030572 100644 --- a/src/test-utils/factories/index.ts +++ b/src/test-utils/factories/index.ts @@ -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"; diff --git a/src/test-utils/factories/tokenMarketData.factory.ts b/src/test-utils/factories/tokenMarketData.factory.ts new file mode 100644 index 00000000..7f448239 --- /dev/null +++ b/src/test-utils/factories/tokenMarketData.factory.ts @@ -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 => ({ + ...DEFAULT_TOKEN_MARKET_DATA, + ...overwrites, +}); diff --git a/src/types/common.ts b/src/types/common.ts index 6855223d..35b8ff8a 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -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. diff --git a/src/types/custom/zapper.ts b/src/types/custom/zapper.ts index a35dd7ed..48c4059b 100644 --- a/src/types/custom/zapper.ts +++ b/src/types/custom/zapper.ts @@ -47,3 +47,56 @@ export enum ZapProtocol { PICKLE = "pickle", YEARN = "yearn", } + +type VaultTokenMarketDataCategory = "deposit" | "pool" | "wallet"; + +export interface VaultTokenMarketData { + address: Address; + appId: "yearn"; + appImageUrl: string; + appName: "Yearn"; + apy?: number; + category: VaultTokenMarketDataCategory; + decimals: number; + img: string; + isBlocked: boolean; + label: string; + liquidity: number; + network: "ethereum"; + price: number; + pricePerShare: number; + protcolDisplay: "Yearn"; + supply: number; + symbol: string; + tokens: VaultTokenMarketDataToken[]; + type: "vault"; +} + +type VaultTokenMarketDataType = "base" | "interest-bearing" | "pool" | "vault"; + +interface VaultTokenMarketDataToken { + address: Address; + appId?: "yearn"; + appImageUrl?: string; + appName?: "Yearn"; + canExchange?: boolean; + category?: VaultTokenMarketDataType; + decimals: number; + exchangeAddress?: Address; + fee?: number; + hide?: boolean; + img?: string; + implementation?: "factoryV2"; + label?: string; + liquidity?: number; + network: "ethereum"; + price: number; + protcolDisplay?: "Yearn"; + reserve: number; + supply?: number; + symbol: string; + tokenImageUrl?: string; + tokens?: unknown[]; // it goes a few levels deep + type: VaultTokenMarketDataType; + volume?: number | null; +} diff --git a/src/types/metadata.ts b/src/types/metadata.ts index 16589304..139fdebf 100644 --- a/src/types/metadata.ts +++ b/src/types/metadata.ts @@ -28,6 +28,8 @@ export interface VaultMetadata { withdrawalsDisabled?: boolean; allowZapIn?: boolean; allowZapOut?: boolean; + zapInWith?: string; + zapOutWith?: string; migrationContract?: Address; migrationTargetVault?: Address; vaultNameOverride?: string; @@ -119,4 +121,6 @@ export interface VaultMetadataOverrides { vaultNameOverride?: string; vaultSymbolOverride?: string; withdrawalsDisabled?: boolean; + zapInWith?: string; + zapOutWith?: string; }