Create a token fetcher
In the Zapper API, a TokenFetcher
class dynamically lists a single group of
AppToken
typed objects. You can see more information
here.
Generate a token fetcher
Our codegen utilities will automatically generate the boilerplate for a token
fetcher. Run pnpm studio create-token-fetcher pickle
. When prompted for the
group, select jar
, and when prompted for the network, select ethereum
.
Implement the token fetcher
Let's open src/apps/pickle/ethereum/pickle.jar.token-fetcher.ts
. The skeleton
has been assembled for you, and you'll now need to fill in the contents of the
getPositions
method in the EthereumPickleJarTokenFetcher
.
import { Inject } from "@nestjs/common";
import { IAppToolkit, APP_TOOLKIT } from "~app-toolkit/app-toolkit.interface";
import { Register } from "~app-toolkit/decorators";
import { PositionFetcher } from "~position/position-fetcher.interface";
import { AppTokenPosition } from "~position/position.interface";
import { Network } from "~types/network.interface";
import { PickleContractFactory } from "../contracts";
import { PICKLE_DEFINITION } from "../pickle.definition";
const appId = PICKLE_DEFINITION.id;
const groupId = PICKLE_DEFINITION.groups.jar.id;
const network = Network.ETHEREUM_MAINNET;
@Register.TokenPositionFetcher({ appId, groupId, network })
export class EthereumPickleJarTokenFetcher
implements PositionFetcher<AppTokenPosition>
{
constructor(
@Inject(APP_TOOLKIT) private readonly appToolkit: IAppToolkit,
@Inject(PickleContractFactory)
private readonly pickleContractFactory: PickleContractFactory
) {}
async getPositions() {
return [];
}
}
You'll notice that the AppToolkit
and PickleContractFactory
have already
been injected into the scope of your class. What are these? The AppToolkit
provides an SDK of utilities to interact with the blockchain, retrieve base
token prices, or even retrieve tokens and positions from other apps defined in
Zapper. The PickleContractFactory
, as explained in the previous section,
builds typed instances of the contract ABIs you have in your
src/pickle/contracts/abis
directory.
Let's get to work!
Resolve all Jar token addresses from the Pickle API
Pickle provides an API endpoint that lists out all of the jar tokens across all supported networks on the Pickle application.
We'll make use of this endpoint to list out all of our tokens.
// ...
// Define a partial of the return type from the Pickle API
export type PickleVaultDetails = {
jarAddress: string;
network: string;
apy: number;
};
@Register.TokenPositionFetcher({ appId, groupId, network })
export class EthereumPickleJarTokenFetcher
implements PositionFetcher<AppTokenPosition>
{
constructor(
@Inject(APP_TOOLKIT) private readonly appToolkit: IAppToolkit,
@Inject(PickleContractFactory)
private readonly pickleContractFactory: PickleContractFactory
) {}
async getPositions() {
// Retrieve pool addresses from the Pickle API
const endpoint = "https://api.pickle.finance/prod/protocol/pools";
const data = await Axios.get<PickleVaultDetails[]>(endpoint).then(
(v) => v.data
);
const ethData = data.filter(({ network }) => network === "eth");
const jarAddresses = ethData.map(({ jarAddress }) =>
jarAddress.toLowerCase()
);
const jarAddressToDetails = _.keyBy(ethData, (v) =>
v.jarAddress.toLowerCase()
);
// Return _anything_ so we can see a result right now!
return jarAddresses as any;
}
}
Easy enough. Let's wire things together and we'll see what happens!
Run the application
We can now run our Pickle application integration and start manually testing
some of our data. We also do not want to run all of the other apps defined in
Studio. Create a .env
file at the root, and update it as follows:
ENABLED_APPS=pickle
Now, we can run the Studio development server in the terminal with pnpm dev
.
If it started successfully, you can now open
http://localhost:5001/apps/pickle/tokens?groupIds[]=jar&network=ethereum
in
your browser. You should see a list of addresses returned, and if you open any
of these addresses in Etherscan, you'll see that they're all Pickle jar tokens.
Hooray 🎉!
Resolve standard ERC20 token properties
Firstly, you may have noticed the return statement being typecast to any
in
our current getPositions
function body. A TokenFetcher
class is actually
expected to return a list of AppTokenPosition
objects.
Let's start by retrieving standard ERC20 properties for each jar token:
symbol
, decimals
, and supply
. We'll be making requests using a typed
Ethers contract instance from our ContractFactory
to request on-chain data.
We'll also optimize this by using a Multicall wrapper utility to batch contract
calls into a single request.
// ...
@Register.TokenPositionFetcher({ appId, groupId, network })
export class EthereumPickleJarTokenFetcher
implements PositionFetcher<AppTokenPosition>
{
constructor(
@Inject(APP_TOOLKIT) private readonly appToolkit: IAppToolkit,
@Inject(PickleContractFactory)
private readonly pickleContractFactory: PickleContractFactory
) {}
async getPositions() {
// ...
// Create a multicall wrapper instance to batch chain RPC calls together
const multicall = this.appToolkit.getMulticall(network);
// We will build a token object for each jar address, using data retrieved on-chain with Ethers
const tokens = await Promise.all(
jarAddresses.map(async (jarAddress) => {
// Instantiate a smart contract instance pointing to the jar token address
const contract = this.pickleContractFactory.pickleJar({
address: jarAddress,
network,
});
// Request the symbol, decimals, ands supply for the jar token
const [symbol, decimals, supplyRaw] = await Promise.all([
multicall.wrap(contract).symbol(),
multicall.wrap(contract).decimals(),
multicall.wrap(contract).totalSupply(),
]);
// Denormalize the supply
const supply = Number(supplyRaw) / 10 ** decimals;
// Create the token object
const token: AppTokenPosition = {
type: ContractType.APP_TOKEN,
appId,
groupId,
address: jarAddress,
network,
symbol,
decimals,
supply,
};
return token;
})
);
return tokens;
}
}
Resolve the underlying token and ratio
In Web3, composability is king. Tokens wrap other tokens with functionality that provides value to the owner. You may have heard the term DeFi Legos for this reason.
In the case of Pickle Finance, a user deposits a token, and receives a vault token that represents an auto-compounding balance of the deposited token. The yield is aggregated from some underlying strategy and returned to the user.
Our AppTokenPosition
object represents this relationship using the tokens
and pricePerShare
properties. The tokens
property is an array of the
underlying tokens of the wrapper token, and the pricePerShare
property
represents the ratio between the balance of the app token and the balance of the
underlying token.
For example, if a user deposits 1100 LOOKS
tokens into a Pickle vault that has
a pricePerShare
of 1.1
, the user receives 1000 pLOOKS
tokens as a receipt.
Conversely, on withdrawal, they would receive 1100 LOOKS
tokens for burning
their 1000 pLOOKS
tokens.
Let's see how to retrieve this data for a Pickle jar token:
// ...
@Register.TokenPositionFetcher({ appId, groupId, network })
export class EthereumPickleJarTokenFetcher
implements PositionFetcher<AppTokenPosition>
{
constructor(
@Inject(APP_TOOLKIT) private readonly appToolkit: IAppToolkit,
@Inject(PickleContractFactory)
private readonly pickleContractFactory: PickleContractFactory
) {}
async getPositions() {
// ...
// A user can deposit base tokens like LOOKS or LQTY
const baseTokenDependencies = await this.appToolkit.getBaseTokenPrices(
network
);
// ...or a user can deposit other app tokens like Uniswap or Curve LP tokens
const appTokenDependencies = await this.appToolkit.getAppTokenPositions(
{ appId: "uniswap-v2", groupIds: ["pool"], network },
{ appId: "curve", groupIds: ["pool"], network }
);
const allTokenDependencies = [
...appTokenDependencies,
...baseTokenDependencies,
];
const tokens = await Promise.all(
jarAddresses.map(async (jarAddress) => {
// ...
// Request the underlying token address and ratio for the jar token
const [underlyingTokenAddressRaw, ratioRaw] = await Promise.all([
multicall
.wrap(contract)
.token()
.catch(() => ""),
multicall
.wrap(contract)
.getRatio()
.catch(() => ""),
]);
// Find the underlying token in our dependencies.
// Note: If it is not found, then we have not indexed the underlying token, and we cannot
// index the jar token since its price depends on the underlying token price.
const underlyingTokenAddress = underlyingTokenAddressRaw.toLowerCase();
const underlyingToken = allTokenDependencies.find(
(v) => v.address === underlyingTokenAddress
);
if (!underlyingToken) return null;
const tokens = [underlyingToken];
// Denormalize the price per share
const pricePerShare = Number(ratioRaw) / 10 ** 18;
const price = pricePerShare * underlyingToken.price;
// Create the token object
const token: AppTokenPosition = {
type: ContractType.APP_TOKEN,
appId,
groupId,
address: jarAddress,
network,
symbol,
decimals,
supply,
tokens,
price,
pricePerShare,
};
return token;
})
);
// Use compact from lodash to filter out any null elements
return _.compact(tokens);
}
}
Resolve any additional data properties
(This is an optional step and can be skipped!)
As mentioned previously, groups of tokens share common patterns on how to
retrieve and build their properties, and may also share common additional
properties. We can define additional properties in the dataProps
field of the
AppTokenPosition
type.
We'll also make the use of generics to properly type our dataProps
. The rest
of our application can use these types when referencing the Pickle jar
tokens.
Let's return the total value locked and the APY as part of the dataProps
.
// ...
// Declare the data properties for a Pickle jar token
export type PickleJarTokenDataProps = {
apy: number;
liquidity: number;
};
@Register.TokenPositionFetcher({ appId, groupId, network })
export class EthereumPickleJarTokenFetcher
implements PositionFetcher<AppTokenPosition>
{
constructor(
@Inject(APP_TOOLKIT) private readonly appToolkit: IAppToolkit,
@Inject(PickleContractFactory)
private readonly pickleContractFactory: PickleContractFactory
) {}
async getPositions() {
// ...
const tokens = await Promise.all(
jarAddresses.map(async (jarAddress) => {
// ...
// Retrieve the APY from the map we created in the first step
const apy = (jarAddressToDetails[jarAddress]?.apy ?? 0) / 100;
// The Liquidity is the deposited reserve times the price of the deposited token
const underlyingTokenContract = this.pickleContractFactory.pickleJar({
address: underlyingToken.address,
network,
});
const [reserveRaw] = await multicall
.wrap(underlyingTokenContract)
.balanceOf(jarAddress);
const reserve = Number(reserveRaw) / 10 ** underlyingToken.decimals;
const liquidity = supplyreserve * underlyingToken.price;
// Create the token object
const token: AppTokenPosition<PickleJarTokenDataProps> = {
// ...
dataProps: {
apy,
liquidity,
},
};
return token;
})
);
return _.compact(tokens);
}
}
We're almost there! Now we just need to tell Zapper how to render this token in our application.
Resolve display properties
What do you mean
bSupercrvRenWBTC
isn't a user-friendly name?
Using the symbol
as a token label is generally not a great solution for human
readability. Instead, we define displayProps
on each token that instructs how
Zapper will render the token in our web and mobile applications. The developer
is in full control to define a label
, secondaryLabel
, and optional
tertiaryLabel
, and images
.
Let's put everything together and observe our finished product!
import { Inject } from "@nestjs/common";
import Axios from "axios";
import _, { compact } from "lodash";
import { IAppToolkit, APP_TOOLKIT } from "~app-toolkit/app-toolkit.interface";
import { Register } from "~app-toolkit/decorators";
import { buildDollarDisplayItem } from "~app-toolkit/helpers/presentation/display-item.present";
import {
getImagesFromToken,
getLabelFromToken,
} from "~app-toolkit/helpers/presentation/image.present";
import { ContractType } from "~position/contract.interface";
import { PositionFetcher } from "~position/position-fetcher.interface";
import { AppTokenPosition } from "~position/position.interface";
import { Network } from "~types/network.interface";
import { PickleContractFactory } from "../contracts";
import { PICKLE_DEFINITION } from "../pickle.definition";
const appId = PICKLE_DEFINITION.id;
const groupId = PICKLE_DEFINITION.groups.jar.id;
const network = Network.ETHEREUM_MAINNET;
export type PickleVaultDetails = {
jarAddress: string;
network: string;
apy: number;
};
export type PickleJarTokenDataProps = {
apy: number;
liquidity: number;
};
@Register.TokenPositionFetcher({ appId, groupId, network })
export class EthereumPickleJarTokenFetcher
implements PositionFetcher<AppTokenPosition>
{
constructor(
@Inject(APP_TOOLKIT) private readonly appToolkit: IAppToolkit,
@Inject(PickleContractFactory)
private readonly pickleContractFactory: PickleContractFactory
) {}
async getPositions() {
// Retrieve pool addresses from the Pickle API
const endpoint = "https://api.pickle.finance/prod/protocol/pools";
const data = await Axios.get<PickleVaultDetails[]>(endpoint).then(
(v) => v.data
);
const ethData = data.filter(({ network }) => network === "eth");
const jarAddresses = ethData.map(({ jarAddress }) =>
jarAddress.toLowerCase()
);
const jarAddressToDetails = _.keyBy(ethData, (v) =>
v.jarAddress.toLowerCase()
);
const baseTokens = await this.appToolkit.getBaseTokenPrices(network);
const appTokens = await this.appToolkit.getAppTokenPositions(
{ appId: "uniswap-v2", groupIds: ["pool"], network },
{ appId: "curve", groupIds: ["pool"], network }
);
const allTokens = [...appTokens, ...baseTokens];
// Build out the token objects
const multicall = this.appToolkit.getMulticall(network);
const tokens = await Promise.all(
jarAddresses.map(async (jarAddress) => {
const contract = this.pickleContractFactory.pickleJar({
address: jarAddress,
network,
});
const [
symbol,
decimals,
supplyRaw,
underlyingTokenAddressRaw,
ratioRaw,
] = await Promise.all([
multicall.wrap(contract).symbol(),
multicall.wrap(contract).decimals(),
multicall.wrap(contract).totalSupply(),
multicall
.wrap(contract)
.token()
.catch(() => ""),
multicall
.wrap(contract)
.getRatio()
.catch(() => ""),
]);
const supply = Number(supplyRaw) / 10 ** decimals;
const underlyingTokenAddress = underlyingTokenAddressRaw.toLowerCase();
const underlyingToken = allTokens.find(
(v) => v.address === underlyingTokenAddress
);
if (!underlyingToken) return null;
const underlyingTokenContract = this.pickleContractFactory.pickleJar({
address: underlyingToken.address,
network,
});
//reserveRaw is used for calculating liquidity
const reserveRaw = await multicall
.wrap(underlyingTokenContract)
.balanceOf(jarAddress);
const tokens = [underlyingToken];
const pricePerShare = Number(ratioRaw) / 10 ** 18;
const price = pricePerShare * underlyingToken.price;
const apy = (jarAddressToDetails[jarAddress]?.apy ?? 0) / 100;
const supplyReserve =
Number(reserveRaw) / 10 ** underlyingToken.decimals;
const liquidity = supplyReserve * underlyingToken.price;
// As a label, we'll use the underlying label (i.e.: 'LOOKS' or 'UNI-V2 LOOKS / ETH'), and suffix it with 'Jar'
const label = `${getLabelFromToken(underlyingToken)} Jar`;
// For images, we'll use the underlying token images as well
const images = getImagesFromToken(underlyingToken);
// For the secondary label, we'll use the price of the jar token
const secondaryLabel = buildDollarDisplayItem(price);
// And for a tertiary label, we'll use the APY
const tertiaryLabel = `${(apy * 100).toFixed(3)}% APY`;
const token: AppTokenPosition<PickleJarTokenDataProps> = {
type: ContractType.APP_TOKEN,
appId,
groupId,
address: jarAddress,
network,
symbol,
decimals,
supply,
tokens,
price,
pricePerShare,
dataProps: {
apy,
liquidity,
},
displayProps: {
label,
images,
secondaryLabel,
tertiaryLabel,
},
};
return token;
})
);
return compact(tokens);
}
}
Visit http://localhost:5001/apps/pickle/tokens?groupIds[]=jar&network=ethereum
again in your browser and you can admire your completed work. Here's an example
of one of the tokens in this list:
[
{
"type": "app-token",
"appId": "pickle",
"groupId": "jar",
"address": "0x5da34d322a4b29488e711419fea36da0d0114d5c",
"network": "ethereum",
"symbol": "pcvxFXSFXS-f",
"decimals": 18,
"supply": 626.7982058885142,
"tokens": [
{
"type": "app-token",
"address": "0xf3a43307dcafa93275993862aae628fcb50dc768",
"network": "ethereum",
"appId": "curve",
"groupId": "pool",
"symbol": "cvxFXSFXS-f",
"decimals": 18,
"supply": 4071690.7636125316,
"price": 69.30530920347803,
"pricePerShare": [0.7190025964001571, 1.2939311591466927],
"tokens": [
{
"type": "base-token",
"network": "ethereum",
"address": "0x3432b6a60d23ca0dfca7761b7ab56459d9c964d0",
"decimals": 18,
"symbol": "FXS",
"price": 34.43
},
{
"type": "base-token",
"network": "ethereum",
"address": "0xfeef77d3f69374f66429c91d732a244f074bdf74",
"decimals": 18,
"symbol": "cvxFXS",
"price": 34.43
}
],
"dataProps": {
"swapAddress": "0xd658a338613198204dca1143ac3f01a722b5d94a",
"liquidity": 282189787.3531121,
"volume": 0,
"fee": 0.0029053369
},
"displayProps": {
"label": "FXS / cvxFXS",
"secondaryLabel": "35% / 64%",
"images": [
"https://storage.googleapis.com/zapper-fi-assets/tokens/ethereum/0x3432b6a60d23ca0dfca7761b7ab56459d9c964d0.png",
"https://storage.googleapis.com/zapper-fi-assets/tokens/ethereum/0xfeef77d3f69374f66429c91d732a244f074bdf74.png"
],
"statsItems": [
{
"label": "Liquidity",
"value": {
"type": "dollar",
"value": 282189787.3531121
}
},
{
"label": "Supply",
"value": {
"type": "number",
"value": 4071690.7636125316
}
},
{
"label": "Volume",
"value": {
"type": "dollar",
"value": 0
}
},
{
"label": "Fee",
"value": {
"type": "pct",
"value": 0.0029053369
}
}
]
}
}
],
"price": 69.3055827335755,
"pricePerShare": 1.0000039467408863,
"dataProps": {
"apy": 0.2532
},
"displayProps": {
"label": "FXS / cvxFXS Jar",
"images": [
"https://storage.googleapis.com/zapper-fi-assets/tokens/ethereum/0x3432b6a60d23ca0dfca7761b7ab56459d9c964d0.png",
"https://storage.googleapis.com/zapper-fi-assets/tokens/ethereum/0xfeef77d3f69374f66429c91d732a244f074bdf74.png"
],
"secondaryLabel": {
"type": "dollar",
"value": 69.3055827335755
},
"tertiaryLabel": "25.320% APY"
}
}
]
This implementation works well, but it is a little naive. We have helper classes to simplify building vault tokens. Helpers make implementations easier and more consistent. You can see how a helper could be used for a vault in Recipes.
In the next section, we'll look into enumerating farms in the same way, with the difference being that farm positions are not tokenized.