From 721fc1cbca8156d798d5d8d648d1c8eed3ba84b3 Mon Sep 17 00:00:00 2001 From: 1emu Date: Thu, 1 Aug 2024 14:52:57 -0300 Subject: [PATCH 1/8] chore: add vesting subgraph client --- src/clients/VestingsSubgraph.ts | 85 ++++++++++++++++++++++++++++++ src/entities/Snapshot/constants.ts | 1 + src/routes/vestings.ts | 7 +++ 3 files changed, 93 insertions(+) create mode 100644 src/clients/VestingsSubgraph.ts diff --git a/src/clients/VestingsSubgraph.ts b/src/clients/VestingsSubgraph.ts new file mode 100644 index 000000000..9fbc902d2 --- /dev/null +++ b/src/clients/VestingsSubgraph.ts @@ -0,0 +1,85 @@ +import fetch from 'isomorphic-fetch' + +import { VESTINGS_QUERY_ENDPOINT } from '../entities/Snapshot/constants' + +import { trimLastForwardSlash } from './utils' + +export class VestingsSubgraph { + static Cache = new Map() + private readonly queryEndpoint: string + + static from(baseUrl: string) { + baseUrl = trimLastForwardSlash(baseUrl) + if (!this.Cache.has(baseUrl)) { + this.Cache.set(baseUrl, new this(baseUrl)) + } + + return this.Cache.get(baseUrl)! + } + + static get() { + return this.from(this.getQueryEndpoint()) + } + + constructor(baseUrl: string) { + this.queryEndpoint = baseUrl + } + + private static getQueryEndpoint() { + if (!VESTINGS_QUERY_ENDPOINT) { + throw new Error( + 'Failed to determine vestings subgraph query endpoint. Please check VESTINGS_QUERY_ENDPOINT env is defined' + ) + } + return VESTINGS_QUERY_ENDPOINT + } + + async getVesting(address: string, blockNumber?: string | number) { + const query = ` + query getVesting($address: String!, $block: Int) { + vestings(where: { id: $address }) { + id + token + owner + beneficiary + revoked + revocable + released + start + cliff + periodDuration + vestedPerPeriod + duration + paused + pausable + stop + linear + total + } + releaseLogs(where: {vesting: $address}, block: $block){ + id + timestamp + amount + } + pausedLogs(where: {vesting: $address}, block: $block){ + id + timestamp + eventType + } + } + ` + + const variables = { address, block: blockNumber } + const response = await fetch(this.queryEndpoint, { + method: 'post', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query, + variables: variables, + }), + }) + + const body = await response.json() + return body?.data + } +} diff --git a/src/entities/Snapshot/constants.ts b/src/entities/Snapshot/constants.ts index a9d17d43a..afde6a798 100644 --- a/src/entities/Snapshot/constants.ts +++ b/src/entities/Snapshot/constants.ts @@ -9,3 +9,4 @@ export const SNAPSHOT_DURATION = Number(process.env.GATSBY_SNAPSHOT_DURATION || export const SNAPSHOT_URL = process.env.GATSBY_SNAPSHOT_URL || 'https://testnet.snapshot.org/' export const SNAPSHOT_QUERY_ENDPOINT = `https://gateway-arbitrum.network.thegraph.com/api/${process.env.THE_GRAPH_API_KEY}/subgraphs/id/4YgtogVaqoM8CErHWDK8mKQ825BcVdKB8vBYmb4avAQo` export const SNAPSHOT_API = process.env.GATSBY_SNAPSHOT_API || '' +export const VESTINGS_QUERY_ENDPOINT = 'https://api.studio.thegraph.com/query/84687/dcl-vestings/version/latest' //TODO: publish subgraph and use prod endpoint diff --git a/src/routes/vestings.ts b/src/routes/vestings.ts index a80d25cdd..02235d8d0 100644 --- a/src/routes/vestings.ts +++ b/src/routes/vestings.ts @@ -3,12 +3,14 @@ import routes from 'decentraland-gatsby/dist/entities/Route/routes' import { Request } from 'express' import { VestingWithLogs } from '../clients/VestingData' +import { VestingsSubgraph } from '../clients/VestingsSubgraph' import { VestingService } from '../services/VestingService' import { validateAddress } from '../utils/validations' export default routes((router) => { router.get('/all-vestings', handleAPI(getAllVestings)) router.post('/vesting', handleAPI(getVestings)) + router.get('/vesting/:address', handleAPI(getVesting)) }) async function getAllVestings() { @@ -21,3 +23,8 @@ async function getVestings(req: Request) { + const address = validateAddress(req.params.address) + return await VestingsSubgraph.get().getVesting(address) +} From 320bb171fdc265053725732a8f10cd1abcb82c2e Mon Sep 17 00:00:00 2001 From: 1emu Date: Fri, 2 Aug 2024 16:47:53 -0300 Subject: [PATCH 2/8] chore: subgraph vesting data parsing WIP --- src/clients/VestingData.ts | 157 ++++++++++++++++++++++++++++++++++--- 1 file changed, 147 insertions(+), 10 deletions(-) diff --git a/src/clients/VestingData.ts b/src/clients/VestingData.ts index 172c89bb8..f1a3d7da7 100644 --- a/src/clients/VestingData.ts +++ b/src/clients/VestingData.ts @@ -3,14 +3,19 @@ import { JsonRpcProvider } from '@ethersproject/providers' import { ethers } from 'ethers' import { VestingStatus } from '../entities/Grant/types' +import { CLIFF_PERIOD_IN_DAYS } from '../entities/Proposal/utils' import { ErrorService } from '../services/ErrorService' import RpcService from '../services/RpcService' import ERC20_ABI from '../utils/contracts/abi/ERC20.abi.json' import VESTING_ABI from '../utils/contracts/abi/vesting/vesting.json' import VESTING_V2_ABI from '../utils/contracts/abi/vesting/vesting_v2.json' import { ContractVersion, TopicsByVersion } from '../utils/contracts/vesting' +import Time from '../utils/date/Time' import { ErrorCategory } from '../utils/errorCategories' +import { SubgraphVesting } from './VestingSubgraphTypes' +import { VestingsSubgraph } from './VestingsSubgraph' + export type VestingLog = { topic: string timestamp: string @@ -27,6 +32,8 @@ export type Vesting = { address: string status: VestingStatus token: string + cliff: string + vestedPerPeriod: number[] } export type VestingWithLogs = Vesting & { logs: VestingLog[] } @@ -121,15 +128,17 @@ async function getVestingContractDataV1( const token = getTokenSymbolFromAddress(tokenContractAddress.toLowerCase()) return { + cliff: Time(start_at).add(CLIFF_PERIOD_IN_DAYS, 'day').getTime().toString(), + vestedPerPeriod: [], ...getVestingDates(contractStart, contractEndsTimestamp), + vested: released + releasable, released, releasable, total, + token, status, start_at, finish_at, - token, - vested: released + releasable, } } @@ -153,6 +162,8 @@ async function getVestingContractDataV2( finish_at = toISOString(contractEndsTimestamp) } + const vestedPerPeriod = ((await vestingContract.getVestedPerPeriod()) ?? []).map(parseContractValue) + const released = parseContractValue(await vestingContract.getReleased()) const releasable = parseContractValue(await vestingContract.getReleasable()) const total = parseContractValue(await vestingContract.getTotal()) @@ -172,26 +183,139 @@ async function getVestingContractDataV2( const token = getTokenSymbolFromAddress(tokenContractAddress) return { + cliff: Time(start_at).add(CLIFF_PERIOD_IN_DAYS, 'day').getTime().toString(), + vestedPerPeriod: vestedPerPeriod, ...getVestingDates(contractStart, contractEndsTimestamp), + vested: released + releasable, released, releasable, total, + token, status, start_at, finish_at, + } +} + +function parseVestingData(vestingData: SubgraphVesting): Vesting { + const contractStart = Number(vestingData.start) + const contractDuration = Number(vestingData.duration) + + const start_at = toISOString(contractStart) + const contractEndsTimestamp = contractStart + contractDuration + const finish_at = toISOString(contractEndsTimestamp) + + const released = Number(vestingData.released) + //TODO: how do we know the releasable for each contract type? + const total = Number(vestingData.total) + + const cliffEnd = Number(vestingData.cliff) + const currentTime = Math.floor(Date.now() / 1000) + let vested = 0 + + console.log('currentTime', currentTime) + console.log('cliffEnd', cliffEnd) + if (currentTime < cliffEnd) { + // If we're before the cliff end, nothing is vested + vested = 0 + } else if (vestingData.linear) { + // Linear vesting after the cliff + if (currentTime >= contractEndsTimestamp) { + vested = total + } else { + const timeElapsed = currentTime - contractStart + vested = (timeElapsed / contractDuration) * total + } + } else { + // Periodic vesting after the cliff + const periodDuration = Number(vestingData.periodDuration) + console.log('currentTime', currentTime) + console.log('contractStart', contractStart) + + const x = (currentTime - contractStart) / periodDuration + console.log('x', x) + const periodsCompleted = Math.floor(x) + console.log('periodsCompleted', periodsCompleted) + + // Sum vested tokens for completed periods + for (let i = 0; i < periodsCompleted && i < vestingData.vestedPerPeriod.length; i++) { + vested += Number(vestingData.vestedPerPeriod[i]) + } + } + + const releasable = vested - released + + let status = getInitialVestingStatus(start_at, finish_at) + if (vestingData.revoked) { + status = VestingStatus.Revoked + } else { + if (vestingData.paused) { + status = VestingStatus.Paused + } + } + + const token = getTokenSymbolFromAddress(vestingData.token) + + return { + address: vestingData.id, + cliff: toISOString(Number(vestingData.cliff)), + vestedPerPeriod: vestingData.vestedPerPeriod.map(Number), + ...getVestingDates(contractStart, contractEndsTimestamp), + vested, + released, + releasable, + total, token, - vested: released + releasable, + status, + start_at, + finish_at, } } -export async function getVestingWithLogs( - vestingAddress: string | null | undefined, - proposalId?: string -): Promise { - if (!vestingAddress || vestingAddress.length === 0) { - throw new Error('Unable to fetch vesting data for empty contract address') +function parseVestingLogs(vestingData: SubgraphVesting) { + const version = vestingData.linear ? ContractVersion.V1 : ContractVersion.V2 + const topics = TopicsByVersion[version] + const logs: VestingLog[] = [] + const parsedReleases: VestingLog[] = vestingData.releaseLogs.map((releaseLog) => { + return { + topic: topics.RELEASE, + timestamp: toISOString(Number(releaseLog.timestamp)), + amount: Number(releaseLog.amount), + } + }) + logs.push(...parsedReleases) + const parsedPauseEvents: VestingLog[] = vestingData.pausedLogs.map((pausedLog) => { + return { + topic: pausedLog.eventType === 'Paused' ? topics.PAUSED : topics.UNPAUSED, + timestamp: toISOString(Number(pausedLog.timestamp)), + } + }) + logs.push(...parsedPauseEvents) + return logs.sort(sortByTimestamp) +} + +export async function getVestingWithLogsFromSubgraph(vestingAddress: string, proposalId?: string) { + try { + const vestingData = await VestingsSubgraph.get().getVesting(vestingAddress) + const vestingContract = parseVestingData(vestingData) + const logs = parseVestingLogs(vestingData) + return { ...vestingContract, logs } + } catch (error) { + console.log('error', error) + ErrorService.report('Unable to fetch vestings subgraph data', { + error, + vestingAddress, + proposalId, + category: ErrorCategory.Vesting, + }) } +} +function sortByTimestamp(a: VestingLog, b: VestingLog) { + return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() +} + +export async function getVestingWithLogsFromAlchemy(vestingAddress: string, proposalId?: string | undefined) { const provider = new ethers.providers.JsonRpcProvider(RpcService.getRpcUrl(ChainId.ETHEREUM_MAINNET)) try { @@ -200,7 +324,7 @@ export async function getVestingWithLogs( const [data, logs] = await Promise.all([dataPromise, logsPromise]) return { ...data, - logs: logs.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()), + logs: logs.sort(sortByTimestamp), address: vestingAddress, } } catch (errorV2) { @@ -225,6 +349,19 @@ export async function getVestingWithLogs( } } +export async function getVestingWithLogs( + vestingAddress: string | null | undefined, + proposalId?: string +): Promise { + if (!vestingAddress || vestingAddress.length === 0) { + throw new Error('Unable to fetch vesting data for empty contract address') + } + + // return await getVestingWithLogsFromSubgraph(vestingAddress, proposalId) + + return await getVestingWithLogsFromAlchemy(vestingAddress, proposalId) +} + function getTokenSymbolFromAddress(tokenAddress: string) { switch (tokenAddress) { case '0x0f5d2fb29fb7d3cfee444a200298f468908cc942': From 123d27e8ed0a8a9d13baff4775c182732baeaef2 Mon Sep 17 00:00:00 2001 From: 1emu Date: Fri, 2 Aug 2024 17:10:52 -0300 Subject: [PATCH 3/8] chore: subgraph vesting data parsing: cliff and pauses --- src/clients/VestingData.ts | 45 ++++++++++++------------ src/clients/VestingSubgraphTypes.ts | 35 +++++++++++++++++++ src/clients/VestingsSubgraph.ts | 53 +++++++++++++++-------------- src/entities/Proposal/utils.ts | 1 + src/routes/vestings.ts | 8 +++-- src/services/ProjectService.ts | 2 +- src/utils/contracts/vesting.ts | 2 +- src/utils/projects.ts | 6 ++++ 8 files changed, 102 insertions(+), 50 deletions(-) create mode 100644 src/clients/VestingSubgraphTypes.ts diff --git a/src/clients/VestingData.ts b/src/clients/VestingData.ts index f1a3d7da7..cc71db279 100644 --- a/src/clients/VestingData.ts +++ b/src/clients/VestingData.ts @@ -3,14 +3,12 @@ import { JsonRpcProvider } from '@ethersproject/providers' import { ethers } from 'ethers' import { VestingStatus } from '../entities/Grant/types' -import { CLIFF_PERIOD_IN_DAYS } from '../entities/Proposal/utils' import { ErrorService } from '../services/ErrorService' import RpcService from '../services/RpcService' import ERC20_ABI from '../utils/contracts/abi/ERC20.abi.json' import VESTING_ABI from '../utils/contracts/abi/vesting/vesting.json' import VESTING_V2_ABI from '../utils/contracts/abi/vesting/vesting_v2.json' import { ContractVersion, TopicsByVersion } from '../utils/contracts/vesting' -import Time from '../utils/date/Time' import { ErrorCategory } from '../utils/errorCategories' import { SubgraphVesting } from './VestingSubgraphTypes' @@ -109,6 +107,8 @@ async function getVestingContractDataV1( const vestingContract = new ethers.Contract(vestingAddress, VESTING_ABI, provider) const contractStart = Number(await vestingContract.start()) const contractDuration = Number(await vestingContract.duration()) + const contractCliff = Number(await vestingContract.cliff()) + console.log('contractCliff', contractCliff) const contractEndsTimestamp = contractStart + contractDuration const start_at = toISOString(contractStart) const finish_at = toISOString(contractEndsTimestamp) @@ -128,7 +128,7 @@ async function getVestingContractDataV1( const token = getTokenSymbolFromAddress(tokenContractAddress.toLowerCase()) return { - cliff: Time(start_at).add(CLIFF_PERIOD_IN_DAYS, 'day').getTime().toString(), + cliff: toISOString(contractCliff), vestedPerPeriod: [], ...getVestingDates(contractStart, contractEndsTimestamp), vested: released + releasable, @@ -149,6 +149,8 @@ async function getVestingContractDataV2( const vestingContract = new ethers.Contract(vestingAddress, VESTING_V2_ABI, provider) const contractStart = Number(await vestingContract.getStart()) const contractDuration = Number(await vestingContract.getPeriod()) + const contractCliff = Number(await vestingContract.getCliff()) + contractStart + console.log('contractCliff V2', contractCliff) let contractEndsTimestamp = 0 const start_at = toISOString(contractStart) @@ -183,7 +185,7 @@ async function getVestingContractDataV2( const token = getTokenSymbolFromAddress(tokenContractAddress) return { - cliff: Time(start_at).add(CLIFF_PERIOD_IN_DAYS, 'day').getTime().toString(), + cliff: toISOString(contractCliff), vestedPerPeriod: vestedPerPeriod, ...getVestingDates(contractStart, contractEndsTimestamp), vested: released + releasable, @@ -200,21 +202,17 @@ async function getVestingContractDataV2( function parseVestingData(vestingData: SubgraphVesting): Vesting { const contractStart = Number(vestingData.start) const contractDuration = Number(vestingData.duration) + const cliffEnd = Number(vestingData.cliff) + const currentTime = Math.floor(Date.now() / 1000) const start_at = toISOString(contractStart) const contractEndsTimestamp = contractStart + contractDuration const finish_at = toISOString(contractEndsTimestamp) const released = Number(vestingData.released) - //TODO: how do we know the releasable for each contract type? const total = Number(vestingData.total) - - const cliffEnd = Number(vestingData.cliff) - const currentTime = Math.floor(Date.now() / 1000) let vested = 0 - console.log('currentTime', currentTime) - console.log('cliffEnd', cliffEnd) if (currentTime < cliffEnd) { // If we're before the cliff end, nothing is vested vested = 0 @@ -229,13 +227,20 @@ function parseVestingData(vestingData: SubgraphVesting): Vesting { } else { // Periodic vesting after the cliff const periodDuration = Number(vestingData.periodDuration) - console.log('currentTime', currentTime) - console.log('contractStart', contractStart) + let timeVested = currentTime - contractStart + + // Adjust for pauses + if (vestingData.pausedLogs && vestingData.pausedLogs.length > 0) { + for (const pausedLog of vestingData.pausedLogs) { + const pauseTimestamp = Number(pausedLog.timestamp) + if (currentTime >= pauseTimestamp) { + timeVested = pauseTimestamp - contractStart + break + } + } + } - const x = (currentTime - contractStart) / periodDuration - console.log('x', x) - const periodsCompleted = Math.floor(x) - console.log('periodsCompleted', periodsCompleted) + const periodsCompleted = Math.floor(timeVested / periodDuration) // Sum vested tokens for completed periods for (let i = 0; i < periodsCompleted && i < vestingData.vestedPerPeriod.length; i++) { @@ -248,17 +253,15 @@ function parseVestingData(vestingData: SubgraphVesting): Vesting { let status = getInitialVestingStatus(start_at, finish_at) if (vestingData.revoked) { status = VestingStatus.Revoked - } else { - if (vestingData.paused) { - status = VestingStatus.Paused - } + } else if (vestingData.paused) { + status = VestingStatus.Paused } const token = getTokenSymbolFromAddress(vestingData.token) return { address: vestingData.id, - cliff: toISOString(Number(vestingData.cliff)), + cliff: toISOString(cliffEnd), vestedPerPeriod: vestingData.vestedPerPeriod.map(Number), ...getVestingDates(contractStart, contractEndsTimestamp), vested, diff --git a/src/clients/VestingSubgraphTypes.ts b/src/clients/VestingSubgraphTypes.ts new file mode 100644 index 000000000..c83e6702e --- /dev/null +++ b/src/clients/VestingSubgraphTypes.ts @@ -0,0 +1,35 @@ +export type SubgraphVesting = { + id: string + version: number + duration: string + cliff: string + beneficiary: string + revoked: boolean + revocable: boolean + released: string + start: string + periodDuration: string + vestedPerPeriod: string[] + paused: boolean + pausable: boolean + stop: string + linear: boolean + token: string + owner: string + total: string + releaseLogs: SubgraphReleaseLog[] + pausedLogs: SubgraphPausedLog[] + revokeTimestamp: bigint +} + +type SubgraphReleaseLog = { + id: string + timestamp: string + amount: string +} + +type SubgraphPausedLog = { + id: string + timestamp: string + eventType: string +} diff --git a/src/clients/VestingsSubgraph.ts b/src/clients/VestingsSubgraph.ts index 9fbc902d2..477b1a614 100644 --- a/src/clients/VestingsSubgraph.ts +++ b/src/clients/VestingsSubgraph.ts @@ -2,6 +2,7 @@ import fetch from 'isomorphic-fetch' import { VESTINGS_QUERY_ENDPOINT } from '../entities/Snapshot/constants' +import { SubgraphVesting } from './VestingSubgraphTypes' import { trimLastForwardSlash } from './utils' export class VestingsSubgraph { @@ -34,42 +35,44 @@ export class VestingsSubgraph { return VESTINGS_QUERY_ENDPOINT } - async getVesting(address: string, blockNumber?: string | number) { + async getVesting(address: string): Promise { const query = ` - query getVesting($address: String!, $block: Int) { - vestings(where: { id: $address }) { - id - token - owner - beneficiary - revoked - revocable - released - start - cliff - periodDuration - vestedPerPeriod - duration - paused - pausable - stop - linear - total - } - releaseLogs(where: {vesting: $address}, block: $block){ + query getVesting($address: String!) { + vestings(where: { id: $address }){ + id + version + duration + cliff + beneficiary + revoked + revocable + released + start + periodDuration + vestedPerPeriod + paused + pausable + stop + linear + token + owner + total + revokeTimestamp + releaseLogs{ id timestamp amount } - pausedLogs(where: {vesting: $address}, block: $block){ + pausedLogs{ id timestamp eventType } + } } ` - const variables = { address, block: blockNumber } + const variables = { address } const response = await fetch(this.queryEndpoint, { method: 'post', headers: { 'Content-Type': 'application/json' }, @@ -80,6 +83,6 @@ export class VestingsSubgraph { }) const body = await response.json() - return body?.data + return body?.data?.vestings[0] || {} } } diff --git a/src/entities/Proposal/utils.ts b/src/entities/Proposal/utils.ts index 6d7facb78..f71c5d99d 100644 --- a/src/entities/Proposal/utils.ts +++ b/src/entities/Proposal/utils.ts @@ -40,6 +40,7 @@ export const SITEMAP_ITEMS_PER_PAGE = 100 export const DEFAULT_CHOICES = ['yes', 'no', 'abstain'] export const REGEX_NAME = new RegExp(`^([a-zA-Z0-9]){${MIN_NAME_SIZE},${MAX_NAME_SIZE}}$`) +//TODO: avoid manually calculating cliff, use subgraph or contract method instead export const CLIFF_PERIOD_IN_DAYS = 29 export function formatBalance(value: number | bigint) { diff --git a/src/routes/vestings.ts b/src/routes/vestings.ts index 02235d8d0..44d118281 100644 --- a/src/routes/vestings.ts +++ b/src/routes/vestings.ts @@ -2,7 +2,7 @@ import handleAPI from 'decentraland-gatsby/dist/entities/Route/handle' import routes from 'decentraland-gatsby/dist/entities/Route/routes' import { Request } from 'express' -import { VestingWithLogs } from '../clients/VestingData' +import { VestingWithLogs, getVestingWithLogsFromAlchemy, getVestingWithLogsFromSubgraph } from '../clients/VestingData' import { VestingsSubgraph } from '../clients/VestingsSubgraph' import { VestingService } from '../services/VestingService' import { validateAddress } from '../utils/validations' @@ -26,5 +26,9 @@ async function getVestings(req: Request) { const address = validateAddress(req.params.address) - return await VestingsSubgraph.get().getVesting(address) + const subgraphVesting = await VestingsSubgraph.get().getVesting(address) + const subVesting = await getVestingWithLogsFromSubgraph(address) + const alchVesting = await getVestingWithLogsFromAlchemy(address) + + return { subgraphVesting, subVesting, alchVesting } } diff --git a/src/services/ProjectService.ts b/src/services/ProjectService.ts index 973dfb2a0..94feb0e2b 100644 --- a/src/services/ProjectService.ts +++ b/src/services/ProjectService.ts @@ -58,7 +58,7 @@ export class ProjectService { proposalVestings.find( (vesting) => vesting.vesting_status === VestingStatus.InProgress || vesting.vesting_status === VestingStatus.Finished - ) || proposalVestings[0] + ) || proposalVestings[0] //TODO: replace transparency vestings for vestings subgraph const project = createProposalProject(proposal, prioritizedVesting) try { diff --git a/src/utils/contracts/vesting.ts b/src/utils/contracts/vesting.ts index 3251db072..3d6cd5a6e 100644 --- a/src/utils/contracts/vesting.ts +++ b/src/utils/contracts/vesting.ts @@ -3,7 +3,7 @@ export enum ContractVersion { V2 = 'v2', } -type Topics = { +export type Topics = { RELEASE: string REVOKE: string TRANSFER_OWNERSHIP: string diff --git a/src/utils/projects.ts b/src/utils/projects.ts index 013c887ca..c8a18e6bd 100644 --- a/src/utils/projects.ts +++ b/src/utils/projects.ts @@ -2,6 +2,9 @@ import { TransparencyVesting } from '../clients/Transparency' import { Vesting } from '../clients/VestingData' import { ProjectStatus, VestingStatus } from '../entities/Grant/types' import { ProjectFunding, ProposalAttributes, ProposalProject, ProposalWithProject } from '../entities/Proposal/types' +import { CLIFF_PERIOD_IN_DAYS } from '../entities/Proposal/utils' + +import Time from './date/Time' export function getHighBudgetVpThreshold(budget: number) { return 1200000 + budget * 40 @@ -83,6 +86,7 @@ export function createProposalProject(proposal: ProposalWithProject, vesting?: T } } +//TODO: stop using transparency vestings export function toVesting(transparencyVesting: TransparencyVesting): Vesting { const { token, @@ -105,6 +109,8 @@ export function toVesting(transparencyVesting: TransparencyVesting): Vesting { total: Math.round(vesting_total_amount), vested: Math.round(vesting_released + vesting_releasable), status: vesting_status, + cliff: Time.unix(Number(vesting_start_at)).add(CLIFF_PERIOD_IN_DAYS, 'day').getTime().toString(), + vestedPerPeriod: [], } return vesting From 23e73396198eb8809258fde6b9e14adebed99421 Mon Sep 17 00:00:00 2001 From: 1emu Date: Mon, 5 Aug 2024 16:05:08 -0300 Subject: [PATCH 4/8] chore: use latest pause log for time vested calculation --- src/clients/VestingData.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/clients/VestingData.ts b/src/clients/VestingData.ts index cc71db279..65aaa0b3e 100644 --- a/src/clients/VestingData.ts +++ b/src/clients/VestingData.ts @@ -229,13 +229,15 @@ function parseVestingData(vestingData: SubgraphVesting): Vesting { const periodDuration = Number(vestingData.periodDuration) let timeVested = currentTime - contractStart - // Adjust for pauses - if (vestingData.pausedLogs && vestingData.pausedLogs.length > 0) { - for (const pausedLog of vestingData.pausedLogs) { - const pauseTimestamp = Number(pausedLog.timestamp) + // Adjust for pauses (we only use the latest pause log. If unpaused, it resumes as if it'd have never been paused) + if (vestingData.paused) { + if (vestingData.pausedLogs && vestingData.pausedLogs.length > 0) { + const latestPauseLog = vestingData.pausedLogs.reduce((latestLog, currentLog) => { + return Number(currentLog.timestamp) > Number(latestLog.timestamp) ? currentLog : latestLog + }, vestingData.pausedLogs[0]) + const pauseTimestamp = Number(latestPauseLog.timestamp) if (currentTime >= pauseTimestamp) { timeVested = pauseTimestamp - contractStart - break } } } From 65f467d006033eb5a33347fa8523117db85843e0 Mon Sep 17 00:00:00 2001 From: 1emu Date: Mon, 5 Aug 2024 16:40:05 -0300 Subject: [PATCH 5/8] chore: replace alchemy vestings usage for subgraph --- src/clients/VestingData.ts | 35 ++------------------ src/clients/VestingsSubgraph.ts | 51 ++++++++++++++++++++++++++++++ src/entities/Updates/model.test.ts | 4 +-- src/routes/vestings.ts | 9 ++---- src/services/ProjectService.ts | 3 +- src/services/VestingService.ts | 45 ++++++++++++++++++++++++-- src/services/update.ts | 5 +-- 7 files changed, 103 insertions(+), 49 deletions(-) diff --git a/src/clients/VestingData.ts b/src/clients/VestingData.ts index 65aaa0b3e..3f37c153d 100644 --- a/src/clients/VestingData.ts +++ b/src/clients/VestingData.ts @@ -12,7 +12,6 @@ import { ContractVersion, TopicsByVersion } from '../utils/contracts/vesting' import { ErrorCategory } from '../utils/errorCategories' import { SubgraphVesting } from './VestingSubgraphTypes' -import { VestingsSubgraph } from './VestingsSubgraph' export type VestingLog = { topic: string @@ -199,7 +198,7 @@ async function getVestingContractDataV2( } } -function parseVestingData(vestingData: SubgraphVesting): Vesting { +export function parseVestingData(vestingData: SubgraphVesting): Vesting { const contractStart = Number(vestingData.start) const contractDuration = Number(vestingData.duration) const cliffEnd = Number(vestingData.cliff) @@ -277,7 +276,7 @@ function parseVestingData(vestingData: SubgraphVesting): Vesting { } } -function parseVestingLogs(vestingData: SubgraphVesting) { +export function parseVestingLogs(vestingData: SubgraphVesting) { const version = vestingData.linear ? ContractVersion.V1 : ContractVersion.V2 const topics = TopicsByVersion[version] const logs: VestingLog[] = [] @@ -299,23 +298,6 @@ function parseVestingLogs(vestingData: SubgraphVesting) { return logs.sort(sortByTimestamp) } -export async function getVestingWithLogsFromSubgraph(vestingAddress: string, proposalId?: string) { - try { - const vestingData = await VestingsSubgraph.get().getVesting(vestingAddress) - const vestingContract = parseVestingData(vestingData) - const logs = parseVestingLogs(vestingData) - return { ...vestingContract, logs } - } catch (error) { - console.log('error', error) - ErrorService.report('Unable to fetch vestings subgraph data', { - error, - vestingAddress, - proposalId, - category: ErrorCategory.Vesting, - }) - } -} - function sortByTimestamp(a: VestingLog, b: VestingLog) { return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() } @@ -354,19 +336,6 @@ export async function getVestingWithLogsFromAlchemy(vestingAddress: string, prop } } -export async function getVestingWithLogs( - vestingAddress: string | null | undefined, - proposalId?: string -): Promise { - if (!vestingAddress || vestingAddress.length === 0) { - throw new Error('Unable to fetch vesting data for empty contract address') - } - - // return await getVestingWithLogsFromSubgraph(vestingAddress, proposalId) - - return await getVestingWithLogsFromAlchemy(vestingAddress, proposalId) -} - function getTokenSymbolFromAddress(tokenAddress: string) { switch (tokenAddress) { case '0x0f5d2fb29fb7d3cfee444a200298f468908cc942': diff --git a/src/clients/VestingsSubgraph.ts b/src/clients/VestingsSubgraph.ts index 477b1a614..7f8fa6d32 100644 --- a/src/clients/VestingsSubgraph.ts +++ b/src/clients/VestingsSubgraph.ts @@ -85,4 +85,55 @@ export class VestingsSubgraph { const body = await response.json() return body?.data?.vestings[0] || {} } + + async getVestings(addresses: string[]): Promise { + const query = ` + query getVestings($addresses: [String]!) { + vestings(where: { id_in: $addresses }){ + id + version + duration + cliff + beneficiary + revoked + revocable + released + start + periodDuration + vestedPerPeriod + paused + pausable + stop + linear + token + owner + total + revokeTimestamp + releaseLogs{ + id + timestamp + amount + } + pausedLogs{ + id + timestamp + eventType + } + } + } + ` + + const variables = { addresses } + const response = await fetch(this.queryEndpoint, { + method: 'post', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query, + variables: variables, + }), + }) + + const body = await response.json() + return body?.data?.vestings || [] + } } diff --git a/src/entities/Updates/model.test.ts b/src/entities/Updates/model.test.ts index 3dd6ac642..db9849646 100644 --- a/src/entities/Updates/model.test.ts +++ b/src/entities/Updates/model.test.ts @@ -1,9 +1,9 @@ import crypto from 'crypto' -import * as VestingUtils from '../../clients/VestingData' import { VestingWithLogs } from '../../clients/VestingData' import { Project } from '../../models/Project' import { ProjectService } from '../../services/ProjectService' +import { VestingService } from '../../services/VestingService' import { UpdateService } from '../../services/update' import Time from '../../utils/date/Time' import { getMonthsBetweenDates } from '../../utils/date/getMonthsBetweenDates' @@ -31,7 +31,7 @@ const MOCK_PROJECT: Project = { } function mockVestingData(vestingDates: VestingWithLogs) { - jest.spyOn(VestingUtils, 'getVestingWithLogs').mockResolvedValue(vestingDates) + jest.spyOn(VestingService, 'getVestingWithLogs').mockResolvedValue(vestingDates) } describe('UpdateModel', () => { diff --git a/src/routes/vestings.ts b/src/routes/vestings.ts index 44d118281..085a0156c 100644 --- a/src/routes/vestings.ts +++ b/src/routes/vestings.ts @@ -2,8 +2,7 @@ import handleAPI from 'decentraland-gatsby/dist/entities/Route/handle' import routes from 'decentraland-gatsby/dist/entities/Route/routes' import { Request } from 'express' -import { VestingWithLogs, getVestingWithLogsFromAlchemy, getVestingWithLogsFromSubgraph } from '../clients/VestingData' -import { VestingsSubgraph } from '../clients/VestingsSubgraph' +import { VestingWithLogs } from '../clients/VestingData' import { VestingService } from '../services/VestingService' import { validateAddress } from '../utils/validations' @@ -26,9 +25,5 @@ async function getVestings(req: Request) { const address = validateAddress(req.params.address) - const subgraphVesting = await VestingsSubgraph.get().getVesting(address) - const subVesting = await getVestingWithLogsFromSubgraph(address) - const alchVesting = await getVestingWithLogsFromAlchemy(address) - - return { subgraphVesting, subVesting, alchVesting } + return await VestingService.getVestingWithLogs(address) } diff --git a/src/services/ProjectService.ts b/src/services/ProjectService.ts index 94feb0e2b..b7e825d77 100644 --- a/src/services/ProjectService.ts +++ b/src/services/ProjectService.ts @@ -1,7 +1,6 @@ import crypto from 'crypto' import { TransparencyVesting } from '../clients/Transparency' -import { getVestingWithLogs } from '../clients/VestingData' import UnpublishedBidModel from '../entities/Bid/model' import { BidProposalConfiguration } from '../entities/Bid/types' import { GrantTier } from '../entities/Grant/GrantTier' @@ -246,7 +245,7 @@ export class ProjectService { private static async updateStatusFromVesting(project: Project) { try { const latestVesting = project.vesting_addresses[project.vesting_addresses.length - 1] - const vestingWithLogs = await getVestingWithLogs(latestVesting) + const vestingWithLogs = await VestingService.getVestingWithLogs(latestVesting) const updatedProjectStatus = toGovernanceProjectStatus(vestingWithLogs.status) await ProjectModel.update({ status: updatedProjectStatus, updated_at: new Date() }, { id: project.id }) diff --git a/src/services/VestingService.ts b/src/services/VestingService.ts index 6bc77667a..2b7d63fa3 100644 --- a/src/services/VestingService.ts +++ b/src/services/VestingService.ts @@ -1,7 +1,11 @@ import { Transparency, TransparencyVesting } from '../clients/Transparency' -import { VestingWithLogs, getVestingWithLogs } from '../clients/VestingData' +import { VestingWithLogs, parseVestingData, parseVestingLogs } from '../clients/VestingData' +import { SubgraphVesting } from '../clients/VestingSubgraphTypes' +import { VestingsSubgraph } from '../clients/VestingsSubgraph' +import { ErrorCategory } from '../utils/errorCategories' import CacheService, { TTL_24_HS } from './CacheService' +import { ErrorService } from './ErrorService' export class VestingService { static async getAllVestings() { @@ -18,9 +22,44 @@ export class VestingService { } static async getVestings(addresses: string[]): Promise { - const vestings = await Promise.all(addresses.map((address) => getVestingWithLogs(address))) + const vestingsData = await VestingsSubgraph.get().getVestings(addresses) + return vestingsData.map(this.parseSubgraphVesting).sort(compareVestingInfo) + } + + static async getVestingWithLogs( + vestingAddress: string | null | undefined, + proposalId?: string + ): Promise { + if (!vestingAddress || vestingAddress.length === 0) { + throw new Error('Unable to fetch vesting data for empty contract address') + } + + return await this.getVestingWithLogsFromSubgraph(vestingAddress, proposalId) + } + + private static async getVestingWithLogsFromSubgraph( + vestingAddress: string, + proposalId?: string + ): Promise { + try { + const subgraphVesting = await VestingsSubgraph.get().getVesting(vestingAddress) + return this.parseSubgraphVesting(subgraphVesting) + } catch (error) { + console.log('Unable to fetch vestings subgraph data', error) //TODO: remove before merging to master + ErrorService.report('Unable to fetch vestings subgraph data', { + error, + vestingAddress, + proposalId, + category: ErrorCategory.Vesting, + }) + throw error + } + } - return vestings.sort(compareVestingInfo) + private static parseSubgraphVesting(vestingData: SubgraphVesting) { + const vestingContract = parseVestingData(vestingData) + const logs = parseVestingLogs(vestingData) + return { ...vestingContract, logs } } } diff --git a/src/services/update.ts b/src/services/update.ts index 337c18e94..bd8c4bbc9 100644 --- a/src/services/update.ts +++ b/src/services/update.ts @@ -3,7 +3,7 @@ import logger from 'decentraland-gatsby/dist/entities/Development/logger' import RequestError from 'decentraland-gatsby/dist/entities/Route/error' import { Discourse } from '../clients/Discourse' -import { VestingWithLogs, getVestingWithLogs } from '../clients/VestingData' +import { VestingWithLogs } from '../clients/VestingData' import { ProposalAttributes } from '../entities/Proposal/types' import UpdateModel from '../entities/Updates/model' import { UpdateAttributes, UpdateStatus } from '../entities/Updates/types' @@ -24,6 +24,7 @@ import Time from '../utils/date/Time' import { getMonthsBetweenDates } from '../utils/date/getMonthsBetweenDates' import { ErrorCategory } from '../utils/errorCategories' +import { VestingService } from './VestingService' import { DiscordService } from './discord' import { EventsService } from './events' @@ -194,7 +195,7 @@ export class UpdateService { const project = await ProjectService.getUpdatedProject(projectId) const { vesting_addresses, proposal_id } = project const vestingAddresses = initialVestingAddresses || vesting_addresses - const vesting = await getVestingWithLogs(vestingAddresses[vestingAddresses.length - 1], proposal_id) + const vesting = await VestingService.getVestingWithLogs(vestingAddresses[vestingAddresses.length - 1], proposal_id) const now = new Date() const updatesQuantity = this.getAmountOfUpdates(vesting) From 7a9c8a5c1146d58681baf5549a2c73afd6432dde Mon Sep 17 00:00:00 2001 From: 1emu Date: Mon, 5 Aug 2024 17:04:43 -0300 Subject: [PATCH 6/8] chore: replace VESTINGS_QUERY_ENDPOINT with published version --- src/entities/Snapshot/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/entities/Snapshot/constants.ts b/src/entities/Snapshot/constants.ts index afde6a798..1c371ab7b 100644 --- a/src/entities/Snapshot/constants.ts +++ b/src/entities/Snapshot/constants.ts @@ -9,4 +9,4 @@ export const SNAPSHOT_DURATION = Number(process.env.GATSBY_SNAPSHOT_DURATION || export const SNAPSHOT_URL = process.env.GATSBY_SNAPSHOT_URL || 'https://testnet.snapshot.org/' export const SNAPSHOT_QUERY_ENDPOINT = `https://gateway-arbitrum.network.thegraph.com/api/${process.env.THE_GRAPH_API_KEY}/subgraphs/id/4YgtogVaqoM8CErHWDK8mKQ825BcVdKB8vBYmb4avAQo` export const SNAPSHOT_API = process.env.GATSBY_SNAPSHOT_API || '' -export const VESTINGS_QUERY_ENDPOINT = 'https://api.studio.thegraph.com/query/84687/dcl-vestings/version/latest' //TODO: publish subgraph and use prod endpoint +export const VESTINGS_QUERY_ENDPOINT = `https://gateway-arbitrum.network.thegraph.com/api/${process.env.THE_GRAPH_API_KEY}/subgraphs/id/Dek4AeCYyGQ8Y2yeVNb2N7cfQDy7Pinka1jD5uWvRCxG` From 30726dfff7e4abb43f49c9ddaf1986e8c0661ad7 Mon Sep 17 00:00:00 2001 From: 1emu Date: Mon, 5 Aug 2024 17:09:44 -0300 Subject: [PATCH 7/8] refactor: remove console logs --- src/clients/VestingData.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/clients/VestingData.ts b/src/clients/VestingData.ts index 3f37c153d..16c52072e 100644 --- a/src/clients/VestingData.ts +++ b/src/clients/VestingData.ts @@ -107,7 +107,6 @@ async function getVestingContractDataV1( const contractStart = Number(await vestingContract.start()) const contractDuration = Number(await vestingContract.duration()) const contractCliff = Number(await vestingContract.cliff()) - console.log('contractCliff', contractCliff) const contractEndsTimestamp = contractStart + contractDuration const start_at = toISOString(contractStart) const finish_at = toISOString(contractEndsTimestamp) @@ -149,7 +148,6 @@ async function getVestingContractDataV2( const contractStart = Number(await vestingContract.getStart()) const contractDuration = Number(await vestingContract.getPeriod()) const contractCliff = Number(await vestingContract.getCliff()) + contractStart - console.log('contractCliff V2', contractCliff) let contractEndsTimestamp = 0 const start_at = toISOString(contractStart) From 838c9b4cbcba0fc235d6596a02a1c479bba3b998 Mon Sep 17 00:00:00 2001 From: 1emu Date: Tue, 6 Aug 2024 14:56:26 -0300 Subject: [PATCH 8/8] refactor: address pr comments --- src/clients/VestingData.ts | 114 ++----------------------- src/services/VestingService.ts | 151 +++++++++++++++++++++++++++++---- 2 files changed, 139 insertions(+), 126 deletions(-) diff --git a/src/clients/VestingData.ts b/src/clients/VestingData.ts index 16c52072e..d68a2b06c 100644 --- a/src/clients/VestingData.ts +++ b/src/clients/VestingData.ts @@ -11,8 +11,6 @@ import VESTING_V2_ABI from '../utils/contracts/abi/vesting/vesting_v2.json' import { ContractVersion, TopicsByVersion } from '../utils/contracts/vesting' import { ErrorCategory } from '../utils/errorCategories' -import { SubgraphVesting } from './VestingSubgraphTypes' - export type VestingLog = { topic: string timestamp: string @@ -35,11 +33,11 @@ export type Vesting = { export type VestingWithLogs = Vesting & { logs: VestingLog[] } -function toISOString(seconds: number) { +export function toISOString(seconds: number) { return new Date(seconds * 1000).toISOString() } -function getVestingDates(contractStart: number, contractEndsTimestamp: number) { +export function getVestingDates(contractStart: number, contractEndsTimestamp: number) { const vestingStartAt = toISOString(contractStart) const vestingFinishAt = toISOString(contractEndsTimestamp) return { @@ -88,7 +86,7 @@ async function getVestingContractLogs(vestingAddress: string, provider: JsonRpcP return logsData } -function getInitialVestingStatus(startAt: string, finishAt: string) { +export function getInitialVestingStatus(startAt: string, finishAt: string) { const now = new Date() if (now < new Date(startAt)) { return VestingStatus.Pending @@ -196,107 +194,7 @@ async function getVestingContractDataV2( } } -export function parseVestingData(vestingData: SubgraphVesting): Vesting { - const contractStart = Number(vestingData.start) - const contractDuration = Number(vestingData.duration) - const cliffEnd = Number(vestingData.cliff) - const currentTime = Math.floor(Date.now() / 1000) - - const start_at = toISOString(contractStart) - const contractEndsTimestamp = contractStart + contractDuration - const finish_at = toISOString(contractEndsTimestamp) - - const released = Number(vestingData.released) - const total = Number(vestingData.total) - let vested = 0 - - if (currentTime < cliffEnd) { - // If we're before the cliff end, nothing is vested - vested = 0 - } else if (vestingData.linear) { - // Linear vesting after the cliff - if (currentTime >= contractEndsTimestamp) { - vested = total - } else { - const timeElapsed = currentTime - contractStart - vested = (timeElapsed / contractDuration) * total - } - } else { - // Periodic vesting after the cliff - const periodDuration = Number(vestingData.periodDuration) - let timeVested = currentTime - contractStart - - // Adjust for pauses (we only use the latest pause log. If unpaused, it resumes as if it'd have never been paused) - if (vestingData.paused) { - if (vestingData.pausedLogs && vestingData.pausedLogs.length > 0) { - const latestPauseLog = vestingData.pausedLogs.reduce((latestLog, currentLog) => { - return Number(currentLog.timestamp) > Number(latestLog.timestamp) ? currentLog : latestLog - }, vestingData.pausedLogs[0]) - const pauseTimestamp = Number(latestPauseLog.timestamp) - if (currentTime >= pauseTimestamp) { - timeVested = pauseTimestamp - contractStart - } - } - } - - const periodsCompleted = Math.floor(timeVested / periodDuration) - - // Sum vested tokens for completed periods - for (let i = 0; i < periodsCompleted && i < vestingData.vestedPerPeriod.length; i++) { - vested += Number(vestingData.vestedPerPeriod[i]) - } - } - - const releasable = vested - released - - let status = getInitialVestingStatus(start_at, finish_at) - if (vestingData.revoked) { - status = VestingStatus.Revoked - } else if (vestingData.paused) { - status = VestingStatus.Paused - } - - const token = getTokenSymbolFromAddress(vestingData.token) - - return { - address: vestingData.id, - cliff: toISOString(cliffEnd), - vestedPerPeriod: vestingData.vestedPerPeriod.map(Number), - ...getVestingDates(contractStart, contractEndsTimestamp), - vested, - released, - releasable, - total, - token, - status, - start_at, - finish_at, - } -} - -export function parseVestingLogs(vestingData: SubgraphVesting) { - const version = vestingData.linear ? ContractVersion.V1 : ContractVersion.V2 - const topics = TopicsByVersion[version] - const logs: VestingLog[] = [] - const parsedReleases: VestingLog[] = vestingData.releaseLogs.map((releaseLog) => { - return { - topic: topics.RELEASE, - timestamp: toISOString(Number(releaseLog.timestamp)), - amount: Number(releaseLog.amount), - } - }) - logs.push(...parsedReleases) - const parsedPauseEvents: VestingLog[] = vestingData.pausedLogs.map((pausedLog) => { - return { - topic: pausedLog.eventType === 'Paused' ? topics.PAUSED : topics.UNPAUSED, - timestamp: toISOString(Number(pausedLog.timestamp)), - } - }) - logs.push(...parsedPauseEvents) - return logs.sort(sortByTimestamp) -} - -function sortByTimestamp(a: VestingLog, b: VestingLog) { +export function sortByTimestamp(a: VestingLog, b: VestingLog) { return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() } @@ -323,7 +221,7 @@ export async function getVestingWithLogsFromAlchemy(vestingAddress: string, prop address: vestingAddress, } } catch (errorV1) { - ErrorService.report('Unable to fetch vesting contract data', { + ErrorService.report('Unable to fetch vesting contract data from alchemy', { proposalId, errorV2: `${errorV2}`, errorV1: `${errorV1}`, @@ -334,7 +232,7 @@ export async function getVestingWithLogsFromAlchemy(vestingAddress: string, prop } } -function getTokenSymbolFromAddress(tokenAddress: string) { +export function getTokenSymbolFromAddress(tokenAddress: string) { switch (tokenAddress) { case '0x0f5d2fb29fb7d3cfee444a200298f468908cc942': return 'MANA' diff --git a/src/services/VestingService.ts b/src/services/VestingService.ts index 2b7d63fa3..d936872a4 100644 --- a/src/services/VestingService.ts +++ b/src/services/VestingService.ts @@ -1,7 +1,19 @@ import { Transparency, TransparencyVesting } from '../clients/Transparency' -import { VestingWithLogs, parseVestingData, parseVestingLogs } from '../clients/VestingData' +import { + Vesting, + VestingLog, + VestingWithLogs, + getInitialVestingStatus, + getTokenSymbolFromAddress, + getVestingDates, + getVestingWithLogsFromAlchemy, + sortByTimestamp, + toISOString, +} from '../clients/VestingData' import { SubgraphVesting } from '../clients/VestingSubgraphTypes' import { VestingsSubgraph } from '../clients/VestingsSubgraph' +import { VestingStatus } from '../entities/Grant/types' +import { ContractVersion, TopicsByVersion } from '../utils/contracts/vesting' import { ErrorCategory } from '../utils/errorCategories' import CacheService, { TTL_24_HS } from './CacheService' @@ -23,7 +35,7 @@ export class VestingService { static async getVestings(addresses: string[]): Promise { const vestingsData = await VestingsSubgraph.get().getVestings(addresses) - return vestingsData.map(this.parseSubgraphVesting).sort(compareVestingInfo) + return vestingsData.map(this.parseSubgraphVesting).sort(this.sortVestingsByDate) } static async getVestingWithLogs( @@ -34,7 +46,11 @@ export class VestingService { throw new Error('Unable to fetch vesting data for empty contract address') } - return await this.getVestingWithLogsFromSubgraph(vestingAddress, proposalId) + try { + return await this.getVestingWithLogsFromSubgraph(vestingAddress, proposalId) + } catch (error) { + return await getVestingWithLogsFromAlchemy(vestingAddress, proposalId) + } } private static async getVestingWithLogsFromSubgraph( @@ -45,7 +61,6 @@ export class VestingService { const subgraphVesting = await VestingsSubgraph.get().getVesting(vestingAddress) return this.parseSubgraphVesting(subgraphVesting) } catch (error) { - console.log('Unable to fetch vestings subgraph data', error) //TODO: remove before merging to master ErrorService.report('Unable to fetch vestings subgraph data', { error, vestingAddress, @@ -57,27 +72,127 @@ export class VestingService { } private static parseSubgraphVesting(vestingData: SubgraphVesting) { - const vestingContract = parseVestingData(vestingData) - const logs = parseVestingLogs(vestingData) + const vestingContract = this.parseVestingData(vestingData) + const logs = this.parseVestingLogs(vestingData) return { ...vestingContract, logs } } -} -function compareVestingInfo(a: VestingWithLogs, b: VestingWithLogs): number { - if (a.logs.length === 0 && b.logs.length === 0) { - return new Date(b.start_at).getTime() - new Date(a.start_at).getTime() - } + private static parseVestingData(vestingData: SubgraphVesting): Vesting { + const contractStart = Number(vestingData.start) + const contractDuration = Number(vestingData.duration) + const cliffEnd = Number(vestingData.cliff) + const currentTime = Math.floor(Date.now() / 1000) + + const start_at = toISOString(contractStart) + const contractEndsTimestamp = contractStart + contractDuration + const finish_at = toISOString(contractEndsTimestamp) + + const released = Number(vestingData.released) + const total = Number(vestingData.total) + let vested = 0 + + if (currentTime < cliffEnd) { + // If we're before the cliff end, nothing is vested + vested = 0 + } else if (vestingData.linear) { + // Linear vesting after the cliff + if (currentTime >= contractEndsTimestamp) { + vested = total + } else { + const timeElapsed = currentTime - contractStart + vested = (timeElapsed / contractDuration) * total + } + } else { + // Periodic vesting after the cliff + const periodDuration = Number(vestingData.periodDuration) + let timeVested = currentTime - contractStart + + // Adjust for pauses (we only use the latest pause log. If unpaused, it resumes as if it'd have never been paused) + if (vestingData.paused) { + if (vestingData.pausedLogs && vestingData.pausedLogs.length > 0) { + const latestPauseLog = vestingData.pausedLogs.reduce((latestLog, currentLog) => { + return Number(currentLog.timestamp) > Number(latestLog.timestamp) ? currentLog : latestLog + }, vestingData.pausedLogs[0]) + const pauseTimestamp = Number(latestPauseLog.timestamp) + if (currentTime >= pauseTimestamp) { + timeVested = pauseTimestamp - contractStart + } + } + } + + const periodsCompleted = Math.floor(timeVested / periodDuration) + + // Sum vested tokens for completed periods + for (let i = 0; i < periodsCompleted && i < vestingData.vestedPerPeriod.length; i++) { + vested += Number(vestingData.vestedPerPeriod[i]) + } + } - if (a.logs.length === 0) { - return -1 + const releasable = vested - released + + let status = getInitialVestingStatus(start_at, finish_at) + if (vestingData.revoked) { + status = VestingStatus.Revoked + } else if (vestingData.paused) { + status = VestingStatus.Paused + } + + const token = getTokenSymbolFromAddress(vestingData.token) + + return { + address: vestingData.id, + cliff: toISOString(cliffEnd), + vestedPerPeriod: vestingData.vestedPerPeriod.map(Number), + ...getVestingDates(contractStart, contractEndsTimestamp), + vested, + released, + releasable, + total, + token, + status, + start_at, + finish_at, + } } - if (b.logs.length === 0) { - return 1 + private static parseVestingLogs(vestingData: SubgraphVesting) { + const version = vestingData.linear ? ContractVersion.V1 : ContractVersion.V2 + const topics = TopicsByVersion[version] + const logs: VestingLog[] = [] + const parsedReleases: VestingLog[] = vestingData.releaseLogs.map((releaseLog) => { + return { + topic: topics.RELEASE, + timestamp: toISOString(Number(releaseLog.timestamp)), + amount: Number(releaseLog.amount), + } + }) + logs.push(...parsedReleases) + const parsedPauseEvents: VestingLog[] = vestingData.pausedLogs.map((pausedLog) => { + return { + topic: pausedLog.eventType === 'Paused' ? topics.PAUSED : topics.UNPAUSED, + timestamp: toISOString(Number(pausedLog.timestamp)), + } + }) + logs.push(...parsedPauseEvents) + return logs.sort(sortByTimestamp) } - const aLatestLogTimestamp = new Date(a.logs[0].timestamp).getTime() - const bLatestLogTimestamp = new Date(b.logs[0].timestamp).getTime() + private static sortVestingsByDate(a: VestingWithLogs, b: VestingWithLogs): number { + if (a.logs.length === 0 && b.logs.length === 0) { + return new Date(b.start_at).getTime() - new Date(a.start_at).getTime() + } + + if (a.logs.length === 0) { + return -1 + } + + if (b.logs.length === 0) { + return 1 + } + + const aLatestLogTimestamp = new Date(a.logs[0].timestamp).getTime() + const bLatestLogTimestamp = new Date(b.logs[0].timestamp).getTime() - return bLatestLogTimestamp - aLatestLogTimestamp + return bLatestLogTimestamp - aLatestLogTimestamp + } }