Skip to content

Commit

Permalink
feat: top voter badge (#1224)
Browse files Browse the repository at this point in the history
* chore: queue top voters airdrop WIP (#1219)

* chore: top voters job pt2 (#1225)

* chore: queue top voters airdrop WIP

* chore: top voters spec definition, env vars, refactors

* chore: top voters badge pt3 (#1226)

* chore: check for existing badge specs before creating spec

* chore: retry badge spec upload and badge spec creation

* chore: move AirdropJob types to types folder

* fix: return data in top voters hook

* fix: minor fixes

* fix: pending airdrop jobs number

* refactor: address pr comments

* refactor: var rename

* refactor: address pr comment

* chore: schedule top voters badge for 5th of september

* refactor: getBadgesSignerAndContract function
  • Loading branch information
1emu authored Aug 31, 2023
1 parent 5c48586 commit 070e0ba
Show file tree
Hide file tree
Showing 18 changed files with 425 additions and 219 deletions.
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ RAFT_OWNER_PK=
NFT_STORAGE_API_KEY=
LEGISLATOR_BADGE_SPEC_CID=
LAND_OWNER_BADGE_SPEC_CID=
TOP_VOTER_BADGE_IMG_URL=

# Front end variables
GATSBY_DEFAULT_CHAIN_ID=11155111
Expand Down Expand Up @@ -103,4 +104,4 @@ DAO_ROLLBAR_TOKEN=""

# Newsletter
BEEHIIV_API_KEY=
BEEHIIV_PUBLICATION_ID=
BEEHIIV_PUBLICATION_ID=
16 changes: 15 additions & 1 deletion src/back/jobs/BadgeAirdrop.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import logger from 'decentraland-gatsby/dist/entities/Development/logger'

import { ActionStatus } from '../../entities/Badges/types'
import { BadgesService } from '../../services/BadgesService'
import { ErrorService } from '../../services/ErrorService'
import { ErrorCategory } from '../../utils/errorCategories'
import { isProdEnv } from '../../utils/governanceEnvs'
import AirdropJobModel, { AirdropJobAttributes } from '../models/AirdropJob'

Expand All @@ -13,7 +16,7 @@ async function runQueuedAirdropJobs() {
if (pendingJobs.length === 0) {
return
}
logger.log(`Running ${pendingJobs} airdrop jobs`)
logger.log(`Running ${pendingJobs.length} airdrop jobs`)
pendingJobs.map(async (pendingJob) => {
const { id, badge_spec, recipients } = pendingJob
const airdropOutcome = await BadgesService.giveBadgeToUsers(badge_spec, recipients)
Expand All @@ -33,3 +36,14 @@ async function giveAndRevokeLandOwnerBadges() {
await BadgesService.giveAndRevokeLandOwnerBadges()
}
}

export async function giveTopVoterBadges() {
const { status, badgeCid, badgeTitle, error } = await BadgesService.createTopVotersBadgeSpec()
if (error && status === ActionStatus.Failed) {
ErrorService.report(error, { category: ErrorCategory.Badges, badgeTitle, badgeCid })
}
if (!badgeCid) {
ErrorService.report('Unable to create top voters badge', { category: ErrorCategory.Badges, badgeTitle })
}
await BadgesService.queueTopVopVoterAirdrops(badgeCid!)
}
10 changes: 2 additions & 8 deletions src/back/models/AirdropJob.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Model } from 'decentraland-gatsby/dist/entities/Database/model'
import { SQL, table } from 'decentraland-gatsby/dist/entities/Database/utils'

import { AirdropJobStatus } from '../types/AirdropJob'

export type AirdropJobAttributes = {
id: string
badge_spec: string
Expand All @@ -11,14 +13,6 @@ export type AirdropJobAttributes = {
updated_at: Date
}

export enum AirdropJobStatus {
PENDING = 'pending',
FINISHED = 'finished',
FAILED = 'failed',
}

export type AirdropOutcome = Pick<AirdropJobAttributes, 'status' | 'error'>

export default class AirdropJobModel extends Model<AirdropJobAttributes> {
static tableName = 'airdrop_jobs'
static withTimestamps = false
Expand Down
15 changes: 10 additions & 5 deletions src/back/routes/badges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ import { Request } from 'express'

import { storeBadgeSpec } from '../../entities/Badges/storeBadgeSpec'
import {
ActionResult,
ActionStatus,
BadgeCreationResult,
RevokeOrReinstateResult,
UserBadges,
toOtterspaceRevokeReason,
} from '../../entities/Badges/types'
import { createSpec } from '../../entities/Badges/utils'
import { BadgesService } from '../../services/BadgesService'
import { AirdropOutcome } from '../models/AirdropJob'
import { AirdropOutcome } from '../types/AirdropJob'
import { createSpec } from '../utils/contractInteractions'
import {
validateAddress,
validateDate,
Expand Down Expand Up @@ -56,7 +56,7 @@ async function airdrop(req: WithAuth): Promise<AirdropOutcome> {
return await BadgesService.giveBadgeToUsers(badgeSpecCid, recipients)
}

async function revoke(req: WithAuth): Promise<ActionResult[]> {
async function revoke(req: WithAuth): Promise<RevokeOrReinstateResult[]> {
const user = req.auth!
const { badgeSpecCid, reason } = req.body
const recipients: string[] = req.body.recipients
Expand Down Expand Up @@ -88,7 +88,12 @@ async function uploadBadgeSpec(req: WithAuth): Promise<BadgeCreationResult> {
validateDate(expiresAt)

try {
const result = await storeBadgeSpec(title, description, imgUrl, expiresAt)
const result = await storeBadgeSpec({
title,
description,
imgUrl,
expiresAt,
})
return { status: ActionStatus.Success, badgeCid: result.badgeCid }
} catch (e) {
return { status: ActionStatus.Failed, error: JSON.stringify(e) }
Expand Down
9 changes: 9 additions & 0 deletions src/back/types/AirdropJob.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { AirdropJobAttributes } from '../models/AirdropJob'

export enum AirdropJobStatus {
PENDING = 'pending',
FINISHED = 'finished',
FAILED = 'failed',
}

export type AirdropOutcome = Pick<AirdropJobAttributes, 'status' | 'error'>
155 changes: 155 additions & 0 deletions src/back/utils/contractInteractions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { abi as BadgesAbi } from '@otterspace-xyz/contracts/out/Badges.sol/Badges.json'
import logger from 'decentraland-gatsby/dist/entities/Development/logger'
import { ethers } from 'ethers'

import { POLYGON_BADGES_CONTRACT_ADDRESS, RAFT_OWNER_PK, TRIMMED_OTTERSPACE_RAFT_ID } from '../../constants'
import { ActionStatus, BadgeCreationResult, GAS_MULTIPLIER, GasConfig } from '../../entities/Badges/types'
import RpcService from '../../services/RpcService'
import { AirdropJobStatus, AirdropOutcome } from '../types/AirdropJob'

const TRANSACTION_UNDERPRICED_ERROR_CODE = -32000

function checksumAddresses(addresses: string[]): string[] {
return addresses.map((address) => ethers.utils.getAddress(address))
}

function getBadgesSignerAndContract() {
const provider = RpcService.getPolygonProvider()
const signer = new ethers.Wallet(RAFT_OWNER_PK, provider)
const contract = new ethers.Contract(POLYGON_BADGES_CONTRACT_ADDRESS, BadgesAbi, signer)
return { signer, contract }
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function estimateGas(estimateFunction: (...args: any[]) => Promise<any>): Promise<GasConfig> {
const provider = RpcService.getPolygonProvider()
const gasLimit = await estimateFunction()
const gasPrice = await provider.getGasPrice()
const adjustedGasPrice = gasPrice.mul(GAS_MULTIPLIER)
return {
gasPrice: adjustedGasPrice,
gasLimit,
}
}

export async function airdrop(badgeCid: string, recipients: string[], shouldPumpGas = false) {
const { signer, contract } = getBadgesSignerAndContract()
const ipfsAddress = `ipfs://${badgeCid}/metadata.json`
const formattedRecipients = checksumAddresses(recipients)
logger.log(`Airdropping, pumping gas ${shouldPumpGas}`)
let txn
if (shouldPumpGas) {
const gasConfig = await estimateGas(async () => contract.estimateGas.airdrop(formattedRecipients, ipfsAddress))
txn = await contract.connect(signer).airdrop(formattedRecipients, ipfsAddress, gasConfig)
} else {
txn = await contract.connect(signer).airdrop(formattedRecipients, ipfsAddress)
}
await txn.wait()
logger.log('Airdropped badge with txn hash:', txn.hash)
return txn.hash
}

export async function reinstateBadge(badgeId: string) {
const { signer, contract } = getBadgesSignerAndContract()
const gasConfig = await estimateGas(async () => {
return contract.estimateGas.reinstateBadge(TRIMMED_OTTERSPACE_RAFT_ID, badgeId)
})

const txn = await contract.connect(signer).reinstateBadge(TRIMMED_OTTERSPACE_RAFT_ID, badgeId, gasConfig)
await txn.wait()
logger.log('Reinstated badge with txn hash:', txn.hash)
return txn.hash
}

export async function revokeBadge(badgeId: string, reason: number) {
const { signer, contract } = getBadgesSignerAndContract()

const gasConfig = await estimateGas(async () => {
return contract.estimateGas.revokeBadge(TRIMMED_OTTERSPACE_RAFT_ID, badgeId, reason)
})

const txn = await contract.connect(signer).revokeBadge(TRIMMED_OTTERSPACE_RAFT_ID, badgeId, reason, gasConfig)
await txn.wait()
logger.log('Revoked badge with txn hash:', txn.hash)
return txn.hash
}

export async function checkBalance() {
const { signer } = getBadgesSignerAndContract()
const balance = await signer.getBalance()
const balanceInEther = ethers.utils.formatEther(balance)
const balanceBigNumber = ethers.BigNumber.from(balance)
console.log(`Balance of ${signer.address}: ${balanceInEther} ETH = ${balanceBigNumber}`)
}

export function trimOtterspaceId(rawId: string) {
const parts = rawId.split(':')
if (parts.length === 2) {
return parts[1]
}
return ''
}

export function getIpfsAddress(badgeCid: string) {
return `ipfs://${badgeCid}/metadata.json`
}

export async function createSpec(badgeCid: string) {
const { signer, contract } = getBadgesSignerAndContract()
const ipfsAddress = `ipfs://${badgeCid}/metadata.json`

const gasConfig = await estimateGas(async () => {
return contract.estimateGas.createSpec(ipfsAddress, TRIMMED_OTTERSPACE_RAFT_ID)
})

const txn = await contract.connect(signer).createSpec(ipfsAddress, TRIMMED_OTTERSPACE_RAFT_ID, gasConfig)
await txn.wait()
logger.log('Create badge spec with txn hash:', txn.hash)
return txn.hash
}

export async function airdropWithRetry(
badgeCid: string,
recipients: string[],
retries = 3,
shouldPumpGas = false
): Promise<AirdropOutcome> {
try {
await airdrop(badgeCid, recipients, shouldPumpGas)
return { status: AirdropJobStatus.FINISHED, error: '' }
} catch (error: any) {
if (retries > 0) {
logger.log(`Retrying airdrop... Attempts left: ${retries}`, error)
const shouldPumpGas = isTransactionUnderpricedError(error)
return await airdropWithRetry(badgeCid, recipients, retries - 1, shouldPumpGas)
} else {
logger.error('Airdrop failed after maximum retries', error)
return { status: AirdropJobStatus.FAILED, error: JSON.stringify(error) }
}
}
}

export async function createSpecWithRetry(badgeCid: string, retries = 3): Promise<BadgeCreationResult> {
try {
await createSpec(badgeCid)
return { status: ActionStatus.Success, badgeCid }
} catch (error: any) {
if (retries > 0) {
logger.log(`Retrying create spec... Attempts left: ${retries}`, error)
return await createSpecWithRetry(badgeCid, retries - 1)
} else {
logger.error('Create spec failed after maximum retries', error)
return { status: ActionStatus.Failed, error, badgeCid }
}
}
}

export function isTransactionUnderpricedError(error: any) {
try {
const errorParsed = JSON.parse(error.body)
const errorCode = errorParsed?.error?.code
return errorCode === TRANSACTION_UNDERPRICED_ERROR_CODE
} catch (e) {
return false
}
}
6 changes: 3 additions & 3 deletions src/clients/Governance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { ApiResponse } from 'decentraland-gatsby/dist/utils/api/types'
import env from 'decentraland-gatsby/dist/utils/env'
import snakeCase from 'lodash/snakeCase'

import { AirdropOutcome } from '../back/models/AirdropJob'
import { AirdropOutcome } from '../back/types/AirdropJob'
import { SpecState } from '../components/Debug/UploadBadgeSpec'
import { GOVERNANCE_API } from '../constants'
import { ActionResult, BadgeCreationResult, UserBadges } from '../entities/Badges/types'
import { BadgeCreationResult, RevokeOrReinstateResult, UserBadges } from '../entities/Badges/types'
import { BidRequest, UnpublishedBidAttributes } from '../entities/Bid/types'
import { Budget, BudgetWithContestants, CategoryBudget } from '../entities/Budget/types'
import { CoauthorAttributes, CoauthorStatus } from '../entities/Coauthor/types'
Expand Down Expand Up @@ -615,7 +615,7 @@ export class Governance extends API {
}

async revokeBadge(badgeSpecCid: string, recipients: string[], reason?: string) {
const response = await this.fetch<ApiResponse<ActionResult[]>>(
const response = await this.fetch<ApiResponse<RevokeOrReinstateResult[]>>(
`/badges/revoke/`,
this.options().method('POST').authorization({ sign: true }).json({
badgeSpecCid,
Expand Down
Loading

0 comments on commit 070e0ba

Please sign in to comment.