/**
The OffsetHelper's purpose is to simplify the carbon offsetting process.
Copyright (C) 2022 Toucan Labs
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import { Client, gql } from "@urql/core";
import { IToucanCarbonOffsets } from "../typechain";
import { Network, PoolSymbol } from "../types";
import {
AggregationsMethod,
AllTCO2TokensMethod,
BridgedBatchTokensMethod,
CustomQueryMethod,
PoolContentsMethod,
ProjectByIdMethod,
RedeemsMethod,
TCO2TokenByFullSymbolMethod,
TCO2TokenByIdMethod,
UserBatchesMethod,
UserRedeemsMethod,
UserRetirementsMethod,
} from "../types/methods";
import {
BridgedBatchTokensResponse,
TCO2TokenResponse,
} from "../types/responses";
import { PairSchema } from "../types/schemas";
import addresses, { INetworkTokenAddresses } from "../utils/addresses";
import { getDexGraphClient, getToucanGraphClient } from "../utils/graphClients";
/**
* @class ContractInteractions
* @description This class helps query Toucan or Toucan-related subgraphs
*/
class SubgraphInteractions {
network: Network;
addresses: INetworkTokenAddresses;
TCO2: IToucanCarbonOffsets | undefined;
graphClient: Client;
/**
*
* @param network network that you want to work on
*/
constructor(network: Network) {
this.network = network;
this.addresses = addresses[this.network];
this.graphClient = getToucanGraphClient(this.network);
}
/**
*
* Note: It's very important that whenever you change the gql query of any existent
* methods, you also change the return type of the method (in types/methods.ts) to
* match it.
*
*/
// --------------------------------------------------------------------------------
// Batches Subgraph Methods
// --------------------------------------------------------------------------------
/**
*
* @description fetches the batches of a user
* @param walletAddress address of user to query for
* @returns an array of BatchTokens (they contain different properties of the Batch)
*/
fetchUserBatches: UserBatchesMethod = async (
walletAddress,
first = 100,
skip = 0
) => {
const query = gql`
query ($walletAddress: String) {
users(id: $walletAddress) {
batchesOwned(orderBy: id, orderDirection: desc) {
id
tx
serialNumber
quantity
confirmationStatus
comments {
id
comment
sender {
id
}
}
creator {
id
}
}
}
}
`;
const result = await this.graphClient
.query(query, { walletAddress, first, skip })
.toPromise();
if (result.error) throw result.error;
if (result.data?.users[0]?.batchesOwned)
return result.data.users[0].batchesOwned;
return [];
};
// --------------------------------------------------------------------------------
// TCO2Tokens Subgraph Methods
// --------------------------------------------------------------------------------
/**
*
* @description fetches properties of a TCO2
* @param id id of the TCO2 to query for; the id happens to be the same as the address e.g.: "0x004090eef602e024b2a6cb7f0c1edda992382994"
* @returns a TCO2Detail object with properties of the TCO2 (name, address, etc)
*/
fetchTCO2TokenById: TCO2TokenByIdMethod = async (id) => {
const query = gql`
query ($id: String) {
tco2Token(id: $id) {
id
name
symbol
address
projectVintage {
name
project {
projectId
}
}
}
}
`;
const result = await this.graphClient.query(query, { id }).toPromise();
if (result.error) throw result.error;
if (result.data?.tco2Tokens) return result.data.tco2Tokens;
return;
};
/**
*
* @description fetches properties of a TCO2
* @param symbol full symbol of the TCO2 to query for e.g.: "TCO2-VCS-1718-2013"
* @returns a TCO2Detail object with properties of the TCO2 (name, address, etc)
*/
fetchTCO2TokenByFullSymbol: TCO2TokenByFullSymbolMethod = async (
symbol: string
) => {
const query = gql`
query ($symbol: String) {
tco2Tokens(where: { symbol: $symbol }) {
id
name
symbol
address
projectVintage {
name
project {
projectId
}
}
}
}
`;
const result = await this.graphClient
.query(query, { symbol: symbol })
.toPromise();
if (result.error) throw result.error;
if (result.data?.tco2Tokens[0]) return result.data.tco2Tokens[0];
return;
};
/**
*
* @description fetches TCO2Details of all TCO2s
* @returns an array of TCO2Detail objects with properties of the TCO2s (name, address, etc)
*/
fetchAllTCO2Tokens: AllTCO2TokensMethod = async (first = 1000, skip = 0) => {
let TCO2Tokens: TCO2TokenResponse[] = [];
for (;;) {
const query = gql`
query ($first: Int, $skip: Int) {
tco2Tokens(first: $first, skip: $skip) {
name
symbol
address
projectVintage {
name
project {
projectId
}
}
}
}
`;
const result = await this.graphClient
.query(query, { first, skip })
.toPromise();
if (result.error) throw result.error;
if (result.data?.tco2Tokens) {
TCO2Tokens = TCO2Tokens.concat(result.data.tco2Tokens);
if (result.data.tco2Tokens.length === first) {
skip += first;
continue;
}
}
break;
}
return TCO2Tokens;
};
// --------------------------------------------------------------------------------
// BatchTokens Subgraph Methods
// --------------------------------------------------------------------------------
/**
*
* @description fetches data about BatchTokens that have been bridged
* @returns an array of BatchTokens containing different properties like id, serialNumber or quantity
*/
fetchBridgedBatchTokens: BridgedBatchTokensMethod = async (
first = 1000,
skip = 0
) => {
let BridgedBatchTokens: BridgedBatchTokensResponse[] = [];
for (;;) {
const query = gql`
query ($retirementStatus: Int, $first: Int, $skip: Int) {
batchTokens(
where: { confirmationStatus: $retirementStatus }
orderBy: timestamp
first: $first
skip: $skip
) {
id
serialNumber
quantity
creator {
id
}
timestamp
tx
}
}
`;
const result = await this.graphClient
.query(query, {
retirementStatus: 2, // RetirementStatus.Confirmed = 2
first,
skip,
})
.toPromise();
if (result.error) throw result.error;
if (result.data?.batchTokens) {
BridgedBatchTokens = BridgedBatchTokens.concat(result.data.batchTokens);
if (result.data.batchTokens.length === first) {
skip += first;
continue;
}
}
break;
}
return BridgedBatchTokens;
};
// --------------------------------------------------------------------------------
// Retirements Subgraph Methods
// --------------------------------------------------------------------------------
/**
*
* @description fetches retirements made by a user
* @param walletAddress address of the user/wallet to query for
* @param first how many retirements you want fetched; defaults to 100
* @param skip how many (if any) retirements you want skipped; defaults to 0
* @returns an array of objects containing properties of the retirements like id, creationTx, amount and more
*/
fetchUserRetirements: UserRetirementsMethod = async (
walletAddress,
first = 100,
skip = 0
) => {
const query = gql`
query ($walletAddress: String, $first: Int, $skip: Int) {
user(id: $walletAddress) {
retirementsCreated(
first: $first
skip: $skip
orderBy: timestamp
orderDirection: desc
) {
id
creationTx
amount
timestamp
token {
symbol
name
address
projectVintage {
name
project {
projectId
}
}
}
certificate {
id
retiringEntity {
id
}
beneficiary {
id
}
retiringEntityString
beneficiaryString
retirementMessage
createdAt
}
}
}
}
`;
const result = await this.graphClient
.query(query, { walletAddress: walletAddress, first, skip })
.toPromise();
if (result.error) throw result.error;
if (result.data?.user?.retirementsCreated)
return result.data.user.retirementsCreated;
return [];
};
// --------------------------------------------------------------------------------
// Redeems Subgraph Methods
// --------------------------------------------------------------------------------
/**
*
* @description fetches redeems of a given pool
* @param pool symbol of pool to fetch for
* @param first how many redeems you want fetched; defaults to 100
* @param skip how many (if any) redeems you want skipped; defaults to 0
* @returns an array of objects with properties of the redeems like id, amount, timestamp and more
*/
fetchRedeems: RedeemsMethod = async (pool, first = 100, skip = 0) => {
const poolAddress = this.getPoolAddress(pool);
const query = gql`
query ($poolAddress: String, $first: Int, $skip: Int) {
redeems(
where: { pool: $poolAddress }
first: $first
skip: $skip
orderBy: timestamp
orderDirection: desc
) {
id
amount
timestamp
creator {
id
}
token {
symbol
name
address
projectVintage {
name
project {
projectId
}
}
}
}
}
`;
const result = await this.graphClient
.query(query, { poolAddress, first, skip })
.toPromise();
if (result.error) throw result.error;
if (result.data?.redeems) return result.data.redeems;
return [];
};
/**
*
* @description fetches redeems of a given pool and user
* @param walletAddress address of the user/wallet to query for
* @param pool symbol of pool to fetch for
* @param first how many redeems you want fetched; defaults to 100
* @param skip how many (if any) redeems you want skipped; defaults to 0
* @returns an array of objects with properties of the redeems like id, amount, timestamp and more
*/
fetchUserRedeems: UserRedeemsMethod = async (
walletAddress,
pool,
first = 100,
skip = 0
) => {
const poolAddress = this.getPoolAddress(pool);
const query = gql`
query (
$walletAddress: String
$poolAddress: String
$first: Int
$skip: Int
) {
user(id: $walletAddress) {
redeemsCreated(
where: { pool: $poolAddress }
first: $first
skip: $skip
orderBy: timestamp
orderDirection: desc
) {
id
amount
timestamp
creator {
id
}
token {
symbol
name
address
projectVintage {
name
project {
projectId
}
}
}
}
}
}
`;
const result = await this.graphClient
.query(query, {
walletAddress,
poolAddress,
first,
skip,
})
.toPromise();
if (result.error) throw result.error;
if (result.data?.user?.redeemsCreated)
return result.data.user.redeemsCreated;
return [];
};
// --------------------------------------------------------------------------------
// PooledTCO2Tokens Subgraph Methods
// --------------------------------------------------------------------------------
/**
*
* @description fetches TCO2 tokens that are part of the given pool
* @param pool symbol of the pool to fetch for
* @param first how many TCO2 tokens you want fetched; defaults to 1000
* @param skip how many (if any) retirements you want skipped; defaults to 0
* @returns an array of objects representing TCO2 tokens and containing properties like name, amount, methodology and more
*/
fetchPoolContents: PoolContentsMethod = async (
pool,
first = 1000,
skip = 0
) => {
const poolAddress = this.getPoolAddress(pool);
const query = gql`
query ($poolAddress: String, $first: Int, $skip: Int) {
pooledTCO2Tokens(
where: { poolAddress: $poolAddress }
first: $first
skip: $skip
orderBy: amount
orderDirection: desc
) {
token {
name
projectVintage {
id
project {
methodology
standard
}
}
}
amount
}
}
`;
const result = await this.graphClient
.query(query, {
poolAddress,
first,
skip,
})
.toPromise();
if (result.error) throw result.error;
if (result.data?.pooledTCO2Tokens) return result.data.pooledTCO2Tokens;
return [];
};
// --------------------------------------------------------------------------------
// Projects Subgraph Methods
// --------------------------------------------------------------------------------
/**
*
* @description fetches a project by its id
* @param id id of the project to fetch; e.g.: "10"
* @returns an object with properties of the Project like projectId, region, standard and more
*/
fetchProjectById: ProjectByIdMethod = async (id) => {
const query = gql`
query ($id: String) {
project(id: $id) {
projectId
region
standard
methodology
vintages {
id
}
}
}
`;
const result = await this.graphClient.query(query, { id }).toPromise();
if (result.error) throw result.error;
if (result.data?.project) return result.data.project;
return;
};
// --------------------------------------------------------------------------------
// Aggregations Subgraph Methods
// --------------------------------------------------------------------------------
/**
*
* @description fetch all aggregations (including, for example, tco2TotalRetired or totalCarbonBridged)
* @returns an array of Aggregation objects containing properties like id, key, value
*/
fetchAggregations: AggregationsMethod = async () => {
const query = gql`
{
aggregations {
id
key
value
}
}
`;
const result = await this.graphClient.query(query).toPromise();
if (result.error) throw result.error;
if (result.data?.aggregations) return result.data.aggregations;
return [];
};
// --------------------------------------------------------------------------------
// Other Subgraph Methods
// --------------------------------------------------------------------------------
/**
*
* @description if pre-made queries to Toucan's Subgraph don't fit all your needs; use this for custom queries
* @param query a gql formated GraphQL query
* @param params any parameters you may want to pass to the query
* @returns all data fetched from query; you can use generics to declare what type to expect (if you're a fan of TS)
*/
fetchCustomQuery: CustomQueryMethod = async (query, params) => {
const result = await this.graphClient
.query(query, {
...(params ?? {}),
})
.toPromise();
if (result.error) throw result.error;
if (result.data) return result.data;
return;
};
// --------------------------------------------------------------------------------
// --------------------------------------------------------------------------------
// Price / Sushiswap related methods
// --------------------------------------------------------------------------------
// --------------------------------------------------------------------------------
private extractPriceInUSD = (
pairs0: PairSchema[],
pairs1: PairSchema[]
): number => {
if (pairs0 && pairs0.length > 0) {
for (const pair of pairs0) {
if (pair.token1.symbol === "USDC") {
return parseFloat(pair.token1Price);
}
}
}
if (pairs1 && pairs1.length > 0) {
for (const pair of pairs1) {
if (pair.token0.symbol === "USDC") {
return parseFloat(pair.token0Price);
}
}
}
return 0;
};
private fetchTokenPrice = async (tokenAddress: string): Promise => {
const DexGraphClient = getDexGraphClient(this.network);
const senderQuery =
this.network === "celo" || this.network === "alfajores"
? gql`
query tokenPairsQuery(
$id: String!
$skip: Int
$block: Block_height
) {
pairs0: pairs(
first: 1000
skip: $skip
orderBy: reserveUSD
orderDirection: desc
token0: $id
block: $block
orderBy: reserveUSD
orderDirection: desc
) {
...pairFields
}
pairs1: pairs(
first: 1000
skip: $skip
orderBy: reserveUSD
orderDirection: desc
token1: $id
block: $block
orderBy: reserveUSD
orderDirection: desc
) {
...pairFields
}
}
fragment pairFields on Pair {
id
reserveUSD
reserveCELO
volumeUSD
untrackedVolumeUSD
trackedReserveUSD
token0 {
...PairToken
}
token1 {
...PairToken
}
reserve0
reserve1
token0Price
token1Price
totalSupply
txCount
}
fragment PairToken on Token {
id
name
symbol
totalSupply
}
`
: gql`
query tokenPairsQuery(
$id: String!
$skip: Int
$block: Block_height
) {
pairs0: pairs(
first: 1000
skip: $skip
orderBy: reserveUSD
orderDirection: desc
token0: $id
block: $block
orderBy: reserveUSD
orderDirection: desc
) {
...pairFields
}
pairs1: pairs(
first: 1000
skip: $skip
orderBy: reserveUSD
orderDirection: desc
token1: $id
block: $block
orderBy: reserveUSD
orderDirection: desc
) {
...pairFields
}
}
fragment pairFields on Pair {
id
reserveUSD
reserveETH
volumeUSD
untrackedVolumeUSD
trackedReserveETH
token0 {
...PairToken
}
token1 {
...PairToken
}
reserve0
reserve1
token0Price
token1Price
totalSupply
txCount
timestamp
}
fragment PairToken on Token {
id
name
symbol
totalSupply
derivedETH
}
`;
const result = await DexGraphClient.query(senderQuery, {
id: tokenAddress,
}).toPromise();
if (result.error) throw result.error;
const priceInUSD = this.extractPriceInUSD(
result.data?.pairs0,
result.data?.pairs1
);
const liquidityUSD =
result.data?.pairs0?.reduce((acc: number, item: PairSchema) => {
acc += parseFloat(item.reserveUSD);
return acc;
}, 0) +
result.data?.pairs1?.reduce((acc: number, item: PairSchema) => {
acc += parseFloat(item.reserveUSD);
return acc;
}, 0);
const volumeUSD =
result.data?.pairs0?.reduce((acc: number, item: PairSchema) => {
acc += parseFloat(item.volumeUSD);
return acc;
}, 0) +
result.data?.pairs1?.reduce((acc: number, item: PairSchema) => {
acc += parseFloat(item.volumeUSD);
return acc;
}, 0);
return [priceInUSD, liquidityUSD, volumeUSD];
};
fetchTokenPriceOnDex = async (
pool: PoolSymbol
): Promise<{
price: number | null;
url: string | null;
liquidityUSD: number | null;
volumeUSD: number | null;
}> => {
const tokenAddress = this.getPoolAddress(pool);
let url = null;
const [price, liquidityUSD, volumeUSD] = await this.fetchTokenPrice(
tokenAddress
);
if (!price) throw new Error(`No price found for ${pool}`);
url =
this.network === "celo" || this.network === "alfajores"
? `https://info.ubeswap.org/token/${tokenAddress}`
: `https://app.sushi.com/analytics/tokens/${tokenAddress}`;
return { price, url, liquidityUSD, volumeUSD };
};
// --------------------------------------------------------------------------------
// Internal methods
// --------------------------------------------------------------------------------
/**
*
* @description gets the contract of a pool token based on the symbol
* @param pool symbol of the pool (token) to use
* @returns a ethers.contract to interact with the pool
*/
private getPoolAddress = (pool: PoolSymbol): string => {
return pool == "BCT" ? this.addresses.bct : this.addresses.nct;
};
}
export default SubgraphInteractions;