Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: top voter badge #1224

Merged
merged 4 commits into from
Aug 31, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/back/jobs/BadgeAirdrop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,8 @@ async function giveAndRevokeLandOwnerBadges() {
await BadgesService.giveAndRevokeLandOwnerBadges()
}
}

export async function giveTopVoterBadges() {
1emu marked this conversation as resolved.
Show resolved Hide resolved
const badgeCiId = await BadgesService.createTopVotersBadge()
await BadgesService.queueTopVopVoterAirdrops(badgeCiId)
}
3 changes: 3 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ export const NFT_STORAGE_API_KEY = process.env.NFT_STORAGE_API_KEY || '' //TODO:
export const LEGISLATOR_BADGE_SPEC_CID = process.env.LEGISLATOR_BADGE_SPEC_CID || ''
export const LAND_OWNER_BADGE_SPEC_CID = process.env.LAND_OWNER_BADGE_SPEC_CID || ''
export const TRIMMED_OTTERSPACE_RAFT_ID = trimOtterspaceId(OTTERSPACE_DAO_RAFT_ID)
export const TOP_VOTERS_PER_MONTH = 3
export const TOP_VOTERS_IMG_URL =
'https://github.com/Decentraland-DAO/badges/blob/master/images/TopVoterBadge-2023.png?raw=true' //TODO: env var
36 changes: 22 additions & 14 deletions src/entities/Badges/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ErrorClient } from '../../clients/ErrorClient'
import { OtterspaceSubgraph } from '../../clients/OtterspaceSubgraph'
import { POLYGON_BADGES_CONTRACT_ADDRESS, RAFT_OWNER_PK, TRIMMED_OTTERSPACE_RAFT_ID } from '../../constants'
import RpcService from '../../services/RpcService'
import Time from '../../utils/date/Time'
import { ErrorCategory } from '../../utils/errorCategories'
import { getUsersWhoVoted, isSameAddress } from '../Snapshot/utils'

Expand Down Expand Up @@ -97,20 +98,6 @@ export function getIpfsAddress(badgeCid: string) {
return `ipfs://${badgeCid}/metadata.json`
}

export async function getLandOwnerAddresses(): Promise<string[]> {
const LAND_API_URL = 'https://api.decentraland.org/v2/tiles?include=owner&type=owned'
type LandOwner = { owner: string }
try {
const response: ApiResponse<{ [coordinates: string]: LandOwner }> = await (await fetch(LAND_API_URL)).json()
const { data: landOwnersMap } = response
const landOwnersAddresses = new Set(Object.values(landOwnersMap).map((landOwner) => landOwner.owner.toLowerCase()))
return Array.from(landOwnersAddresses)
} catch (error) {
ErrorClient.report("Couldn't fetch land owners", { error, category: ErrorCategory.Badges })
return []
}
}

export async function createSpec(badgeCid: string) {
const provider = RpcService.getPolygonProvider()
const raftOwner = new ethers.Wallet(RAFT_OWNER_PK, provider)
Expand Down Expand Up @@ -174,3 +161,24 @@ export async function getValidatedUsersForBadge(badgeCid: string, addresses: str
return { eligibleUsers: [], usersWithBadgesToReinstate: [], error: JSON.stringify(error) }
}
}

// TODO: separate utils for DAO badges from utils for contract interactions

export async function getLandOwnerAddresses(): Promise<string[]> {
const LAND_API_URL = 'https://api.decentraland.org/v2/tiles?include=owner&type=owned'
type LandOwner = { owner: string }
try {
const response: ApiResponse<{ [coordinates: string]: LandOwner }> = await (await fetch(LAND_API_URL)).json()
const { data: landOwnersMap } = response
const landOwnersAddresses = new Set(Object.values(landOwnersMap).map((landOwner) => landOwner.owner.toLowerCase()))
return Array.from(landOwnersAddresses)
} catch (error) {
ErrorClient.report("Couldn't fetch land owners", { error, category: ErrorCategory.Badges })
return []
}
}

export function getTopVoterBadgeTitle(start: Date) {
const startTime = Time.utc(start)
return `Top Voter ${startTime.format('MMMM')} ${startTime.format('YYYY')}`
}
2 changes: 2 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,14 @@ import { activateProposals, finishProposal, publishBids } from './entities/Propo
import { DiscordService } from './services/DiscordService'
import filesystem from './utils/filesystem'

const FIRST_DAY_OF_EACH_MONTH = '0 0 1 * *'
const jobs = manager()
jobs.cron('@eachMinute', finishProposal)
jobs.cron('@eachMinute', activateProposals)
jobs.cron('@eachMinute', publishBids)
jobs.cron('@daily', updateGovernanceBudgets)
jobs.cron('@daily', runAirdropJobs)
// jobs.cron(FIRST_DAY_OF_EACH_MONTH, giveTopVoterBadges); // TODO: release the kraken

const file = readFileSync('static/api.yaml', 'utf8')
const swaggerDocument = YAML.parse(file)
Expand Down
55 changes: 46 additions & 9 deletions src/services/BadgesService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,15 @@ import logger from 'decentraland-gatsby/dist/entities/Development/logger'
import { v1 as uuid } from 'uuid'

import AirdropJobModel, { AirdropJobStatus, AirdropOutcome } from '../back/models/AirdropJob'
import { VoteService } from '../back/services/vote'
import { OtterspaceBadge, OtterspaceSubgraph } from '../clients/OtterspaceSubgraph'
import { LAND_OWNER_BADGE_SPEC_CID, LEGISLATOR_BADGE_SPEC_CID } from '../constants'
import {
LAND_OWNER_BADGE_SPEC_CID,
LEGISLATOR_BADGE_SPEC_CID,
TOP_VOTERS_IMG_URL,
TOP_VOTERS_PER_MONTH,
} from '../constants'
import { storeBadgeSpec } from '../entities/Badges/storeBadgeSpec'
import {
ActionResult,
ActionStatus,
Expand All @@ -17,7 +24,9 @@ import {
} from '../entities/Badges/types'
import {
airdrop,
createSpec,
getLandOwnerAddresses,
getTopVoterBadgeTitle,
getValidatedUsersForBadge,
reinstateBadge,
revokeBadge,
Expand All @@ -28,6 +37,8 @@ import { CoauthorStatus } from '../entities/Coauthor/types'
import { ProposalAttributes, ProposalType } from '../entities/Proposal/types'
import { getChecksumAddress } from '../entities/Snapshot/utils'
import { inBackground, splitArray } from '../helpers'
import Time from '../utils/date/Time'
import { getPreviousMonthStartAndEnd } from '../utils/date/getPreviousMonthStartAndEnd'
import { ErrorCategory } from '../utils/errorCategories'

import { ErrorService } from './ErrorService'
Expand Down Expand Up @@ -138,7 +149,15 @@ export class BadgesService {
const coauthors = await CoauthorModel.findAllByProposals(governanceProposals, CoauthorStatus.APPROVED)
const authors = governanceProposals.map((proposal) => proposal.user)
const authorsAndCoauthors = new Set([...authors.map(getChecksumAddress), ...coauthors.map(getChecksumAddress)])
await this.queueAirdropJob(LEGISLATOR_BADGE_SPEC_CID, Array.from(authorsAndCoauthors))
const recipients = Array.from(authorsAndCoauthors)
if (!LEGISLATOR_BADGE_SPEC_CID || LEGISLATOR_BADGE_SPEC_CID.length === 0) {
ErrorService.report('Unable to create AirdropJob. LEGISLATOR_BADGE_SPEC_CID missing.', {
category: ErrorCategory.Badges,
recipients,
})
return
}
await this.queueAirdropJob(LEGISLATOR_BADGE_SPEC_CID, recipients)
}

static async giveAndRevokeLandOwnerBadges() {
Expand Down Expand Up @@ -191,13 +210,6 @@ export class BadgesService {
}

private static async queueAirdropJob(badgeSpec: string, recipients: string[]) {
if (!LEGISLATOR_BADGE_SPEC_CID || LEGISLATOR_BADGE_SPEC_CID.length === 0) {
ErrorService.report('Unable to create AirdropJob. LEGISLATOR_BADGE_SPEC_CID missing.', {
category: ErrorCategory.Badges,
recipients,
})
return
}
logger.log(`Enqueueing airdrop job`, { badgeSpec, recipients })
try {
await AirdropJobModel.create({ id: uuid(), badge_spec: badgeSpec, recipients })
Expand Down Expand Up @@ -272,4 +284,29 @@ export class BadgesService {
await reinstateBadge(badgeId)
})
}

static async createTopVotersBadge() {
const today = Time.utc()
const { start } = getPreviousMonthStartAndEnd(today.toDate())
const result = await storeBadgeSpec(
getTopVoterBadgeTitle(start),
'top voter badge description', // TODO: missing description
TOP_VOTERS_IMG_URL,
today.endOf('month').toISOString()
)
const { badgeCid } = result
await createSpec(badgeCid) // TODO: create with retries
return badgeCid
}

static async queueTopVopVoterAirdrops(badgeCid: string) {
const today = Time.utc().toDate()
const { start, end } = getPreviousMonthStartAndEnd(today)
const recipients = await VoteService.getTopVoters(start, end, TOP_VOTERS_PER_MONTH)
// TODO: check recipients don't already have a badge for this month
await this.queueAirdropJob(
badgeCid,
recipients.map((recipient) => recipient.address)
)
}
}
16 changes: 16 additions & 0 deletions src/utils/date/getPreviousMonthStartAndEnd.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Time from './Time'
import { getPreviousMonthStartAndEnd } from './getPreviousMonthStartAndEnd'

describe('getPreviousMonthStartAndEnd', () => {
it('calculates start and end dates correctly', () => {
const middleOfYear = Time.utc('2023-07-15').toDate()
const middleOfYearResult = getPreviousMonthStartAndEnd(middleOfYear)
expect(middleOfYearResult.start.toISOString()).toBe('2023-06-01T00:00:00.000Z')
expect(middleOfYearResult.end.toISOString()).toBe('2023-06-30T23:59:59.999Z')

const beginningOfYear = Time.utc('2023-01-01').toDate()
const beginningOfYearResult = getPreviousMonthStartAndEnd(beginningOfYear)
expect(beginningOfYearResult.start.toISOString()).toBe('2022-12-01T00:00:00.000Z')
expect(beginningOfYearResult.end.toISOString()).toBe('2022-12-31T23:59:59.999Z')
})
})
9 changes: 9 additions & 0 deletions src/utils/date/getPreviousMonthStartAndEnd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Time from './Time'

export function getPreviousMonthStartAndEnd(today: Date) {
const pastMonth = Time.utc(today).subtract(1, 'month')
const start = pastMonth.startOf('month').toDate()
const end = pastMonth.endOf('month').toDate()

return { start, end }
}
Loading