diff --git a/.env.example b/.env.example index c26c73c22..1e58a356c 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -103,4 +104,4 @@ DAO_ROLLBAR_TOKEN="" # Newsletter BEEHIIV_API_KEY= -BEEHIIV_PUBLICATION_ID= \ No newline at end of file +BEEHIIV_PUBLICATION_ID= diff --git a/src/back/jobs/BadgeAirdrop.ts b/src/back/jobs/BadgeAirdrop.ts index 3b704a016..2904654f4 100644 --- a/src/back/jobs/BadgeAirdrop.ts +++ b/src/back/jobs/BadgeAirdrop.ts @@ -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' @@ -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) @@ -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!) +} diff --git a/src/back/models/AirdropJob.ts b/src/back/models/AirdropJob.ts index 3f0ccc5a2..3fc89d53c 100644 --- a/src/back/models/AirdropJob.ts +++ b/src/back/models/AirdropJob.ts @@ -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 @@ -11,14 +13,6 @@ export type AirdropJobAttributes = { updated_at: Date } -export enum AirdropJobStatus { - PENDING = 'pending', - FINISHED = 'finished', - FAILED = 'failed', -} - -export type AirdropOutcome = Pick - export default class AirdropJobModel extends Model { static tableName = 'airdrop_jobs' static withTimestamps = false diff --git a/src/back/routes/badges.ts b/src/back/routes/badges.ts index 79705baa5..ca44e9d07 100644 --- a/src/back/routes/badges.ts +++ b/src/back/routes/badges.ts @@ -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, @@ -56,7 +56,7 @@ async function airdrop(req: WithAuth): Promise { return await BadgesService.giveBadgeToUsers(badgeSpecCid, recipients) } -async function revoke(req: WithAuth): Promise { +async function revoke(req: WithAuth): Promise { const user = req.auth! const { badgeSpecCid, reason } = req.body const recipients: string[] = req.body.recipients @@ -88,7 +88,12 @@ async function uploadBadgeSpec(req: WithAuth): Promise { 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) } diff --git a/src/back/types/AirdropJob.ts b/src/back/types/AirdropJob.ts new file mode 100644 index 000000000..6a243af95 --- /dev/null +++ b/src/back/types/AirdropJob.ts @@ -0,0 +1,9 @@ +import { AirdropJobAttributes } from '../models/AirdropJob' + +export enum AirdropJobStatus { + PENDING = 'pending', + FINISHED = 'finished', + FAILED = 'failed', +} + +export type AirdropOutcome = Pick diff --git a/src/back/utils/contractInteractions.ts b/src/back/utils/contractInteractions.ts new file mode 100644 index 000000000..730e5c3e2 --- /dev/null +++ b/src/back/utils/contractInteractions.ts @@ -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): Promise { + 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 { + 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 { + 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 + } +} diff --git a/src/clients/Governance.ts b/src/clients/Governance.ts index 8874ce426..3937b06dc 100644 --- a/src/clients/Governance.ts +++ b/src/clients/Governance.ts @@ -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' @@ -615,7 +615,7 @@ export class Governance extends API { } async revokeBadge(badgeSpecCid: string, recipients: string[], reason?: string) { - const response = await this.fetch>( + const response = await this.fetch>( `/badges/revoke/`, this.options().method('POST').authorization({ sign: true }).json({ badgeSpecCid, diff --git a/src/clients/OtterspaceSubgraph.ts b/src/clients/OtterspaceSubgraph.ts index 1e454ab5a..1707201d5 100644 --- a/src/clients/OtterspaceSubgraph.ts +++ b/src/clients/OtterspaceSubgraph.ts @@ -67,30 +67,50 @@ query Badges($badgeCid: String!, $addresses: [String]!, $first: Int!, $skip: Int } ` -export type OtterspaceBadge = { +const BADGE_SPEC_BY_TITLE = ` +query BadgeSpecTitles($raft_id: String! $name: String!, $first: Int!, $skip: Int!) { + badges: badgeSpecs( + where: {raft: $raft_id,metadata_: {name: $name} }, + first: $first, skip: $skip, + orderBy: createdAt + orderDirection: desc + ) { + id + metadata { + name + description + expiresAt + image + } + } +}` + +export type OtterspaceBadgeSpec = { id: string - createdAt: number - status: string - statusReason: string - owner?: { id: string } - spec: { + metadata: { + name: string + description: string + expiresAt?: number | null + image: string + } + raft?: { id: string metadata: { name: string - description: string - expiresAt?: number | null image: string } - raft: { - id: string - metadata: { - name: string - image: string - } - } } } +export type OtterspaceBadge = { + id: string + createdAt: number + status: string + statusReason: string + owner?: { id: string } + spec: OtterspaceBadgeSpec +} + type BadgeOwnership = { id: string; address: string } export class OtterspaceSubgraph { @@ -132,7 +152,7 @@ export class OtterspaceSubgraph { body: JSON.stringify({ query: BADGES_QUERY, variables: { ...vars, skip, first }, - operationName: 'Badges', + operationName: 'BadgesForAddress', extensions: { headers: null }, }), }) @@ -177,7 +197,7 @@ export class OtterspaceSubgraph { body: JSON.stringify({ query: RECIPIENTS_BADGE_ID_QUERY, variables: { ...vars, skip, first }, - operationName: 'Badges', + operationName: 'RecipientsBadgeId', extensions: { headers: null }, }), }) @@ -192,4 +212,30 @@ export class OtterspaceSubgraph { return { id: badge.id, address: badge.owner?.id || '' } }) } + + async getBadgeSpecByTitle(title: string) { + const badgeSpecs: OtterspaceBadgeSpec[] = await inBatches( + async (vars, skip, first) => { + const response = await fetch(this.queryEndpoint, { + method: 'post', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: BADGE_SPEC_BY_TITLE, + variables: { ...vars, skip, first }, + operationName: 'BadgeSpecsByTitle', + extensions: { headers: null }, + }), + }) + + const body = await response.json() + return body?.data?.badges || [] + }, + { raft_id: OTTERSPACE_DAO_RAFT_ID, name: title }, + 20 + ) + + return badgeSpecs.map((badge) => { + return badge.metadata.name + }) + } } diff --git a/src/components/Debug/UploadBadgeSpec.tsx b/src/components/Debug/UploadBadgeSpec.tsx index c06995d72..3fe143af2 100644 --- a/src/components/Debug/UploadBadgeSpec.tsx +++ b/src/components/Debug/UploadBadgeSpec.tsx @@ -4,8 +4,8 @@ import { SubmitHandler, useForm } from 'react-hook-form' import { Button } from 'decentraland-ui/dist/components/Button/Button' import { Field as DCLField } from 'decentraland-ui/dist/components/Field/Field' +import { getIpfsAddress } from '../../back/utils/contractInteractions' import { Governance } from '../../clients/Governance' -import { getIpfsAddress } from '../../entities/Badges/utils' import useFormatMessage from '../../hooks/useFormatMessage' import Time from '../../utils/date/Time' import Field from '../Common/Form/Field' diff --git a/src/constants.ts b/src/constants.ts index 37db1132b..d5e24d4f4 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,4 +1,4 @@ -import { trimOtterspaceId } from './entities/Badges/utils' +import { trimOtterspaceId } from './back/utils/contractInteractions' import { OTTERSPACE_DAO_RAFT_ID } from './entities/Snapshot/constants' import Candidates from './utils/delegates/candidates.json' import { env } from './utils/env' @@ -25,8 +25,10 @@ export const VOTES_VP_THRESHOLD = 5 export const SSO_URL = env('GATSBY_SSO_URL') ?? undefined export const RAFT_OWNER_PK = process.env.RAFT_OWNER_PK || '' export const POLYGON_BADGES_CONTRACT_ADDRESS = process.env.POLYGON_BADGES_CONTRACT_ADDRESS || '' -export const POLYGON_RAFTS_CONTRACT_ADDRESS = process.env.POLYGON_RAFTS_CONTRACT_ADDRESS || '' //TODO: add to definitions -export const NFT_STORAGE_API_KEY = process.env.NFT_STORAGE_API_KEY || '' //TODO: add to definitions +export const POLYGON_RAFTS_CONTRACT_ADDRESS = process.env.POLYGON_RAFTS_CONTRACT_ADDRESS || '' +export const NFT_STORAGE_API_KEY = process.env.NFT_STORAGE_API_KEY || '' 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_VOTER_BADGE_IMG_URL = process.env.TOP_VOTER_BADGE_IMG_URL || '' diff --git a/src/entities/Badges/storeBadgeSpec.ts b/src/entities/Badges/storeBadgeSpec.ts index e4d26184a..dbc8bfa48 100644 --- a/src/entities/Badges/storeBadgeSpec.ts +++ b/src/entities/Badges/storeBadgeSpec.ts @@ -1,6 +1,8 @@ +import logger from 'decentraland-gatsby/dist/entities/Development/logger' import { ethers } from 'ethers' import { NFTStorage } from 'nft.storage' +import { getIpfsAddress } from '../../back/utils/contractInteractions' import { NFT_STORAGE_API_KEY, POLYGON_RAFTS_CONTRACT_ADDRESS, @@ -8,7 +10,7 @@ import { TRIMMED_OTTERSPACE_RAFT_ID, } from '../../constants' -import { getIpfsAddress } from './utils' +import { ActionStatus, BadgeCreationResult } from './types' const blobToFile = (theBlob: Blob, fileName: string): File => { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -48,7 +50,14 @@ function convertToISODate(dateString: string): string { return isoDateString } -export async function storeBadgeSpec(title: string, description: string, imgUrl: string, expiresAt?: string) { +interface BadgeSpec { + title: string + description: string + imgUrl: string + expiresAt?: string +} + +export async function storeBadgeSpec({ title, description, imgUrl, expiresAt }: BadgeSpec) { const client = new NFTStorage({ token: NFT_STORAGE_API_KEY }) const raftOwner = new ethers.Wallet(RAFT_OWNER_PK) const file = await getImageFileFromUrl(imgUrl) @@ -73,3 +82,19 @@ export async function storeBadgeSpec(title: string, description: string, imgUrl: const ipfsAddress = getIpfsAddress(badgeCid) return { badgeCid: badgeCid, metadataUrl, ipfsAddress } } + +export async function storeBadgeSpecWithRetry(badgeSpec: BadgeSpec, retries = 3): Promise { + const badgeTitle = badgeSpec.title + try { + const { badgeCid } = await storeBadgeSpec(badgeSpec) + return { status: ActionStatus.Success, badgeCid, badgeTitle } + } catch (error: any) { + if (retries > 0) { + logger.log(`Retrying upload spec... Attempts left: ${retries}`, error) + return await storeBadgeSpecWithRetry(badgeSpec, retries - 1) + } else { + logger.error('Upload spec failed after maximum retries', error) + return { status: ActionStatus.Failed, error, badgeTitle } + } + } +} diff --git a/src/entities/Badges/types.ts b/src/entities/Badges/types.ts index 3da8e8c1a..eced07e36 100644 --- a/src/entities/Badges/types.ts +++ b/src/entities/Badges/types.ts @@ -41,9 +41,8 @@ export enum ActionStatus { Success = 'Success', } -export type ActionResult = { status: ActionStatus; address: string; badgeId: string; error?: string } -export type BadgeCreationResult = { status: ActionStatus; badgeCid?: string; error?: string } - +export type RevokeOrReinstateResult = { status: ActionStatus; address: string; badgeId: string; error?: string } +export type BadgeCreationResult = { status: ActionStatus; badgeCid?: string; error?: string; badgeTitle?: string } export type GasConfig = { gasPrice: ethers.BigNumber; gasLimit: ethers.BigNumber } export const GAS_MULTIPLIER = 2 diff --git a/src/entities/Badges/utils.ts b/src/entities/Badges/utils.ts index 38b91ddea..c57a77754 100644 --- a/src/entities/Badges/utils.ts +++ b/src/entities/Badges/utils.ts @@ -1,131 +1,16 @@ -import { abi as BadgesAbi } from '@otterspace-xyz/contracts/out/Badges.sol/Badges.json' -import logger from 'decentraland-gatsby/dist/entities/Development/logger' import { ApiResponse } from 'decentraland-gatsby/dist/utils/api/types' -import { ethers } from 'ethers' 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 { TOP_VOTER_BADGE_IMG_URL } from '../../constants' +import Time from '../../utils/date/Time' +import { getPreviousMonthStartAndEnd } from '../../utils/date/getPreviousMonthStartAndEnd' import { ErrorCategory } from '../../utils/errorCategories' import { getUsersWhoVoted, isSameAddress } from '../Snapshot/utils' -import { BadgeStatus, BadgeStatusReason, ErrorReason, GAS_MULTIPLIER, GasConfig } from './types' +import { BadgeStatus, BadgeStatusReason, ErrorReason } from './types' -function checksumAddresses(addresses: string[]): string[] { - return addresses.map((address) => ethers.utils.getAddress(address)) -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export async function estimateGas(estimateFunction: (...args: any[]) => Promise): Promise { - 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[], pumpGas = false) { - const provider = RpcService.getPolygonProvider() - const raftOwner = new ethers.Wallet(RAFT_OWNER_PK, provider) - const contract = new ethers.Contract(POLYGON_BADGES_CONTRACT_ADDRESS, BadgesAbi, raftOwner) - const ipfsAddress = `ipfs://${badgeCid}/metadata.json` - const formattedRecipients = checksumAddresses(recipients) - logger.log(`Airdropping, pumping gas ${pumpGas}`) - let txn - if (pumpGas) { - const gasConfig = await estimateGas(async () => contract.estimateGas.airdrop(formattedRecipients, ipfsAddress)) - txn = await contract.connect(raftOwner).airdrop(formattedRecipients, ipfsAddress, gasConfig) - } else { - txn = await contract.connect(raftOwner).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 provider = RpcService.getPolygonProvider() - const raftOwner = new ethers.Wallet(RAFT_OWNER_PK, provider) - const contract = new ethers.Contract(POLYGON_BADGES_CONTRACT_ADDRESS, BadgesAbi, raftOwner) - const gasConfig = await estimateGas(async () => { - return contract.estimateGas.reinstateBadge(TRIMMED_OTTERSPACE_RAFT_ID, badgeId) - }) - - const txn = await contract.connect(raftOwner).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 provider = RpcService.getPolygonProvider() - const raftOwner = new ethers.Wallet(RAFT_OWNER_PK, provider) - const contract = new ethers.Contract(POLYGON_BADGES_CONTRACT_ADDRESS, BadgesAbi, raftOwner) - - const gasConfig = await estimateGas(async () => { - return contract.estimateGas.revokeBadge(TRIMMED_OTTERSPACE_RAFT_ID, badgeId, reason) - }) - - const txn = await contract.connect(raftOwner).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 provider = RpcService.getPolygonProvider() - const raftOwner = new ethers.Wallet(RAFT_OWNER_PK, provider) - const balance = await raftOwner.getBalance() - const balanceInEther = ethers.utils.formatEther(balance) - const balanceBigNumber = ethers.BigNumber.from(balance) - console.log(`Balance of ${raftOwner.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 getLandOwnerAddresses(): Promise { - 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) - const contract = new ethers.Contract(POLYGON_BADGES_CONTRACT_ADDRESS, BadgesAbi, raftOwner) - 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(raftOwner).createSpec(ipfsAddress, TRIMMED_OTTERSPACE_RAFT_ID, gasConfig) - await txn.wait() - logger.log('Create badge spec with txn hash:', txn.hash) - return txn.hash -} +const TOP_VOTER_TITLE_PREFIX = `Top Voter` export async function getUsersWithoutBadge(badgeCid: string, users: string[]) { const badges = await OtterspaceSubgraph.get().getBadges(badgeCid) @@ -174,3 +59,44 @@ export async function getValidatedUsersForBadge(badgeCid: string, addresses: str return { eligibleUsers: [], usersWithBadgesToReinstate: [], error: JSON.stringify(error) } } } + +export async function getLandOwnerAddresses(): Promise { + 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(formattedMonth: string, formattedYear: string) { + return `${TOP_VOTER_TITLE_PREFIX} - ${formattedMonth} ${formattedYear}` +} + +export function getTopVoterBadgeDescription(formattedMonth: string, formattedYear: string) { + return `This account belongs to an incredibly engaged Decentraland DAO user who secured one of the top 3 positions in the Voters ranking for ${formattedMonth} ${formattedYear}. By actively expressing their opinions through votes on DAO Proposals, they play a fundamental role in the development of the open metaverse.` +} + +export function getTopVotersBadgeSpec() { + const today = Time.utc() + const { start } = getPreviousMonthStartAndEnd(today.toDate()) + const startTime = Time.utc(start) + const formattedMonth = startTime.format('MMMM') + const formattedYear = startTime.format('YYYY') + return { + title: getTopVoterBadgeTitle(formattedMonth, formattedYear), + description: getTopVoterBadgeDescription(formattedMonth, formattedYear), + imgUrl: TOP_VOTER_BADGE_IMG_URL, + expiresAt: today.endOf('month').toISOString(), + } +} + +export async function isSpecAlreadyCreated(title: string): Promise { + const existingBadge = await OtterspaceSubgraph.get().getBadgeSpecByTitle(title) + return !!existingBadge[0] +} diff --git a/src/migrations/1692114399675_create-airdrop-jobs-table.ts b/src/migrations/1692114399675_create-airdrop-jobs-table.ts index bd45dd1ce..3ea5113ea 100644 --- a/src/migrations/1692114399675_create-airdrop-jobs-table.ts +++ b/src/migrations/1692114399675_create-airdrop-jobs-table.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { ColumnDefinitions, MigrationBuilder } from "node-pg-migrate" -import AirdropJobModel, { AirdropJobStatus } from "../back/models/AirdropJob" +import AirdropJobModel from "../back/models/AirdropJob" +import { AirdropJobStatus } from "../back/types/AirdropJob" export const shorthands: ColumnDefinitions | undefined = undefined diff --git a/src/server.ts b/src/server.ts index 6814b0c47..af36eeab8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,7 +15,7 @@ import { register } from 'prom-client' import swaggerUi from 'swagger-ui-express' import YAML from 'yaml' -import { runAirdropJobs } from './back/jobs/BadgeAirdrop' +import { giveTopVoterBadges, runAirdropJobs } from './back/jobs/BadgeAirdrop' import badges from './back/routes/badges' import bid from './back/routes/bid' import budget from './back/routes/budget' @@ -40,12 +40,16 @@ 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 FIFTH_OF_SEPTEMBER = '0 0 5 9 *' // TODO: remove after 05-09-2013 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) +jobs.cron(FIFTH_OF_SEPTEMBER, giveTopVoterBadges) // TODO: remove after 05-09-2013 const file = readFileSync('static/api.yaml', 'utf8') const swaggerDocument = YAML.parse(file) diff --git a/src/services/BadgesService.ts b/src/services/BadgesService.ts index 6b41d74b6..b1c4bbe45 100644 --- a/src/services/BadgesService.ts +++ b/src/services/BadgesService.ts @@ -1,39 +1,48 @@ import logger from 'decentraland-gatsby/dist/entities/Development/logger' import { v1 as uuid } from 'uuid' -import AirdropJobModel, { AirdropJobStatus, AirdropOutcome } from '../back/models/AirdropJob' +import AirdropJobModel from '../back/models/AirdropJob' +import { VoteService } from '../back/services/vote' +import { AirdropJobStatus, AirdropOutcome } from '../back/types/AirdropJob' +import { + airdropWithRetry, + createSpecWithRetry, + reinstateBadge, + revokeBadge, + trimOtterspaceId, +} from '../back/utils/contractInteractions' 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_PER_MONTH } from '../constants' +import { storeBadgeSpecWithRetry } from '../entities/Badges/storeBadgeSpec' import { - ActionResult, ActionStatus, Badge, + BadgeCreationResult, BadgeStatus, BadgeStatusReason, ErrorReason, OtterspaceRevokeReason, + RevokeOrReinstateResult, UserBadges, toBadgeStatus, } from '../entities/Badges/types' import { - airdrop, getLandOwnerAddresses, + getTopVotersBadgeSpec, getValidatedUsersForBadge, - reinstateBadge, - revokeBadge, - trimOtterspaceId, + isSpecAlreadyCreated, } from '../entities/Badges/utils' import CoauthorModel from '../entities/Coauthor/model' 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' -const TRANSACTION_UNDERPRICED_ERROR_CODE = -32000 - export class BadgesService { public static async getBadges(address: string): Promise { const otterspaceBadges: OtterspaceBadge[] = await OtterspaceSubgraph.get().getBadgesForAddress(address) @@ -96,49 +105,26 @@ export class BadgesService { return { status: AirdropJobStatus.FAILED, error } } - return await this.airdropWithRetry(badgeCid, eligibleUsers) + return await airdropWithRetry(badgeCid, eligibleUsers) } catch (e) { return { status: AirdropJobStatus.FAILED, error: JSON.stringify(e) } } } - private static async airdropWithRetry( - badgeCid: string, - recipients: string[], - retries = 3, - pumpGas = false - ): Promise { - try { - await airdrop(badgeCid, recipients, pumpGas) - return { status: AirdropJobStatus.FINISHED, error: '' } - } catch (error: any) { - if (retries > 0) { - logger.log(`Retrying airdrop... Attempts left: ${retries}`, error) - const pumpGas = this.isTransactionUnderpricedError(error) - return await this.airdropWithRetry(badgeCid, recipients, retries - 1, pumpGas) - } else { - logger.error('Airdrop failed after maximum retries', error) - return { status: AirdropJobStatus.FAILED, error: JSON.stringify(error) } - } - } - } - - private static 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 - } - } - static async giveLegislatorBadges(acceptedProposals: ProposalAttributes[]) { const governanceProposals = acceptedProposals.filter((proposal) => proposal.type === ProposalType.Governance) 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() { @@ -191,13 +177,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 }) @@ -221,7 +200,7 @@ export class BadgesService { return [] } - const actionResults = await Promise.all( + const actionResults = await Promise.all( badgeOwnerships.map(async (badgeOwnership) => { const trimmedId = trimOtterspaceId(badgeOwnership.id) @@ -241,8 +220,7 @@ export class BadgesService { address: badgeOwnership.address, badgeId: trimmedId, } - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore + // eslint-disable-next-line } catch (error: any) { return { status: ActionStatus.Failed, @@ -261,7 +239,7 @@ export class BadgesService { badgeCid: string, addresses: string[], reason = OtterspaceRevokeReason.TenureEnded - ): Promise { + ): Promise { return await this.performBadgeAction(badgeCid, addresses, async (badgeId) => { await revokeBadge(badgeId, Number(reason)) }) @@ -272,4 +250,26 @@ export class BadgesService { await reinstateBadge(badgeId) }) } + + static async createTopVotersBadgeSpec(): Promise { + const badgeSpec = getTopVotersBadgeSpec() + + if (await isSpecAlreadyCreated(badgeSpec.title)) { + return { status: ActionStatus.Failed, error: `Top Voter badge already exists`, badgeTitle: badgeSpec.title } + } + const result = await storeBadgeSpecWithRetry(badgeSpec) + + if (result.status === ActionStatus.Failed || !result.badgeCid) return result + return await createSpecWithRetry(result.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) + await this.queueAirdropJob( + badgeCid, + recipients.map((recipient) => recipient.address) + ) + } } diff --git a/src/utils/date/getPreviousMonthStartAndEnd.test.ts b/src/utils/date/getPreviousMonthStartAndEnd.test.ts new file mode 100644 index 000000000..f9d5664c2 --- /dev/null +++ b/src/utils/date/getPreviousMonthStartAndEnd.test.ts @@ -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') + }) +}) diff --git a/src/utils/date/getPreviousMonthStartAndEnd.ts b/src/utils/date/getPreviousMonthStartAndEnd.ts new file mode 100644 index 000000000..92fe305bd --- /dev/null +++ b/src/utils/date/getPreviousMonthStartAndEnd.ts @@ -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 } +}