Skip to content

Commit

Permalink
feat: Multiple vestings for grants (#1162)
Browse files Browse the repository at this point in the history
* migration started

* changes on vesting module started

* vesting items view

* added chevron

* minor fix

* enactment modal updated

* requested changes

* requested changes

* changed modal title

* added button when there's only 1 contract

* minor fix
  • Loading branch information
ncomerci committed Aug 14, 2023
1 parent aa6b8ea commit 4a441cc
Show file tree
Hide file tree
Showing 27 changed files with 560 additions and 168 deletions.
22 changes: 14 additions & 8 deletions src/back/routes/proposal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ import { ProposalInCreation, ProposalService } from '../../services/ProposalServ
import { getProfile } from '../../utils/Catalyst'
import Time from '../../utils/date/Time'
import { ErrorCategory } from '../../utils/errorCategories'
import { validateAddress } from '../utils/validations'
import { validateAddress, validateUniqueAddresses } from '../utils/validations'

export default routes((route) => {
const withAuth = auth()
Expand Down Expand Up @@ -523,25 +523,31 @@ export async function updateProposalStatus(req: WithAuth<Request<{ proposal: str
if (update.status === ProposalStatus.Enacted) {
update.enacted = true
update.enacted_by = user
update.enacted_description = configuration.description || null
if (proposal.type == ProposalType.Grant) {
update.vesting_address = configuration.vesting_address
update.enacting_tx = configuration.enacting_tx
const { vesting_addresses } = configuration
if (!vesting_addresses || vesting_addresses.length === 0) {
throw new RequestError('Vesting addresses are required for grant proposals', RequestError.BadRequest)
}
if (vesting_addresses.some((address) => !isEthereumAddress(address))) {
throw new RequestError('Some vesting address is invalid', RequestError.BadRequest)
}
if (!validateUniqueAddresses(vesting_addresses)) {
throw new RequestError('Vesting addresses must be unique', RequestError.BadRequest)
}
update.vesting_addresses = vesting_addresses
update.textsearch = ProposalModel.textsearch(
proposal.title,
proposal.description,
proposal.user,
update.vesting_address
update.vesting_addresses
)
const vestingContractData = await getVestingContractData(id, update.vesting_address)
const vestingContractData = await getVestingContractData(vesting_addresses[vesting_addresses.length - 1], id)
await UpdateModel.createPendingUpdates(id, vestingContractData, proposal.configuration.vestingStartDate)
}
} else if (update.status === ProposalStatus.Passed) {
update.passed_by = user
update.passed_description = configuration.description || null
} else if (update.status === ProposalStatus.Rejected) {
update.rejected_by = user
update.rejected_description = configuration.description || null
}

await ProposalModel.update<ProposalAttributes>(update, { id })
Expand Down
18 changes: 18 additions & 0 deletions src/back/routes/vestings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import handleAPI from 'decentraland-gatsby/dist/entities/Route/handle'
import routes from 'decentraland-gatsby/dist/entities/Route/routes'
import { Request } from 'express'

import { VestingInfo } from '../../clients/VestingData'
import { VestingService } from '../../services/VestingService'
import { validateAddress } from '../utils/validations'

export default routes((router) => {
router.post('/vesting', handleAPI(getVestingInfo))
})

async function getVestingInfo(req: Request<any, any, { addresses: string[] }>): Promise<VestingInfo[]> {
const addresses = req.body.addresses
addresses.forEach(validateAddress)

return await VestingService.getVestingInfo(addresses)
}
6 changes: 6 additions & 0 deletions src/back/utils/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,9 @@ export function validateAddress(address: string) {
throw new RequestError('Invalid address', RequestError.BadRequest)
}
}

export function validateUniqueAddresses(addresses: string[]): boolean {
const uniqueSet = new Set(addresses.map((address) => address.toLowerCase()))

return uniqueSet.size === addresses.length
}
1 change: 1 addition & 0 deletions src/clients/DclData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export enum CommitteeName {
SAB = 'Security Advisory Board',
DAOCommitee = 'DAO Committee',
WearableCuration = 'Wearable Curation Committee',
Revocation = 'Revocation Committee',
}

export type Committee = {
Expand Down
21 changes: 11 additions & 10 deletions src/clients/Governance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import Time from '../utils/date/Time'

import { TransparencyBudget } from './DclData'
import { SnapshotProposal, SnapshotSpace, SnapshotStatus, SnapshotVote, VpDistribution } from './SnapshotGraphqlTypes'
import { VestingInfo } from './VestingData'

type NewProposalMap = {
[`/proposals/poll`]: NewProposalPoll
Expand Down Expand Up @@ -212,20 +213,12 @@ export class Governance extends API {
return result.data
}

async updateProposalStatus(
proposal_id: string,
status: ProposalStatus,
vesting_address: string | null,
enacting_tx: string | null,
description: string | null = null
) {
async updateProposalStatus(proposal_id: string, status: ProposalStatus, vesting_addresses?: string[]) {
const result = await this.fetch<ApiResponse<ProposalAttributes>>(
`/proposals/${proposal_id}`,
this.options().method('PATCH').authorization({ sign: true }).json({
status,
vesting_address,
enacting_tx,
description,
vesting_addresses,
})
)

Expand Down Expand Up @@ -550,6 +543,14 @@ export class Governance extends API {
return response.data
}

async getVestingContractData(addresses: string[]) {
const response = await this.fetch<ApiResponse<VestingInfo[]>>(
`/vesting`,
this.options().method('POST').json({ addresses })
)
return response.data
}

async getUpdateComments(update_id: string) {
const result = await this.fetch<ApiResponse<ProposalCommentsInDiscourse>>(`/proposals/${update_id}/update/comments`)
return result.data
Expand Down
72 changes: 67 additions & 5 deletions src/clients/VestingData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AbiItem } from 'web3-utils'
import DclRpcService from '../services/DclRpcService'
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 { ErrorCategory } from '../utils/errorCategories'

import { ErrorClient } from './ErrorClient'
Expand All @@ -13,6 +14,16 @@ export type VestingDates = {
vestingFinishAt: string
}

export type VestingLog = {
topic: string
timestamp: string
}

export type VestingInfo = VestingDates & {
address: string
logs: VestingLog[]
}

function toISOString(seconds: number) {
return new Date(seconds * 1000).toISOString()
}
Expand All @@ -26,6 +37,43 @@ function getVestingDates(contractStart: number, contractEndsTimestamp: number) {
}
}

async function getVestingContractLogs(vestingAddress: string, web3: Web3, version: ContractVersion) {
const eth = web3.eth
const web3Logs = await eth.getPastLogs({
address: vestingAddress,
fromBlock: 13916992, // 01/01/2022
toBlock: 'latest',
})

const blocks = await Promise.all(web3Logs.map((log) => eth.getBlock(log.blockNumber)))
const logs: VestingLog[] = []
const topics = TopicsByVersion[version]

const getLog = (timestamp: number, topic: string): VestingLog => ({
topic,
timestamp: toISOString(timestamp),
})

for (const idx in web3Logs) {
const eventTimestamp = Number(blocks[idx].timestamp)
switch (web3Logs[idx].topics[0]) {
case topics.REVOKE:
logs.push(getLog(eventTimestamp, topics.REVOKE))
break
case topics.PAUSED:
logs.push(getLog(eventTimestamp, topics.PAUSED))
break
case topics.UNPAUSED:
logs.push(getLog(eventTimestamp, topics.UNPAUSED))
break
default:
break
}
}

return logs
}

async function getVestingContractDataV1(vestingAddress: string, web3: Web3): Promise<VestingDates> {
const vestingContract = new web3.eth.Contract(VESTING_ABI as AbiItem[], vestingAddress)
const contractStart = Number(await vestingContract.methods.start().call())
Expand All @@ -51,19 +99,33 @@ async function getVestingContractDataV2(vestingAddress: string, web3: Web3): Pro
}

export async function getVestingContractData(
proposalId: string,
vestingAddress: string | null | undefined
): Promise<VestingDates> {
vestingAddress: string | null | undefined,
proposalId?: string
): Promise<VestingInfo> {
if (!vestingAddress || vestingAddress.length === 0) {
throw new Error('Unable to fetch vesting data for empty contract address')
}

const web3 = new Web3(DclRpcService.getRpcUrl())
try {
return await getVestingContractDataV2(vestingAddress, web3)
const datesPromise = getVestingContractDataV2(vestingAddress, web3)
const logsPromise = getVestingContractLogs(vestingAddress, web3, ContractVersion.V2)
const [dates, logs] = await Promise.all([datesPromise, logsPromise])
return {
...dates,
logs: logs.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()),
address: vestingAddress,
}
} catch (errorV2) {
try {
return await getVestingContractDataV1(vestingAddress, web3)
const datesPromise = getVestingContractDataV1(vestingAddress, web3)
const logsPromise = getVestingContractLogs(vestingAddress, web3, ContractVersion.V1)
const [dates, logs] = await Promise.all([datesPromise, logsPromise])
return {
...dates,
logs: logs.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()),
address: vestingAddress,
}
} catch (errorV1) {
ErrorClient.report('Unable to fetch vesting contract data', {
proposalId,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
.content.ProposalModal__GrantTransaction {
.content .ProposalModal__GrantTransaction {
margin: 24px auto !important;
width: 480px !important;
}

.ProposalModal__GrantTransaction input {
font-size: 18px !important;
}
Loading

0 comments on commit 4a441cc

Please sign in to comment.