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

fix: Badge airdrops in batches #1223

Merged
merged 5 commits into from
Aug 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
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: 4 additions & 1 deletion src/back/jobs/BadgeAirdrop.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logger from 'decentraland-gatsby/dist/entities/Development/logger'

import { BadgesService } from '../../services/BadgesService'
import { isProdEnv } from '../../utils/governanceEnvs'
import AirdropJobModel, { AirdropJobAttributes } from '../models/AirdropJob'

export async function runAirdropJobs() {
Expand Down Expand Up @@ -28,5 +29,7 @@ async function runQueuedAirdropJobs() {
}

async function giveAndRevokeLandOwnerBadges() {
await BadgesService.giveAndRevokeLandOwnerBadges()
if (isProdEnv()) {
await BadgesService.giveAndRevokeLandOwnerBadges()
}
}
6 changes: 6 additions & 0 deletions src/entities/Badges/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ export type Badge = {
createdAt: number
}

export enum ErrorReason {
NoUserWithoutBadge = 'All recipients already have this badge',
NoUserHasVoted = 'Recipients have never voted',
InvalidBadgeId = 'Invalid badge ID',
}

export type UserBadges = { currentBadges: Badge[]; expiredBadges: Badge[]; total: number }

export enum OtterspaceRevokeReason {
Expand Down
52 changes: 51 additions & 1 deletion src/entities/Badges/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ 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 { ErrorCategory } from '../../utils/errorCategories'
import { getUsersWhoVoted, isSameAddress } from '../Snapshot/utils'

import { GAS_MULTIPLIER, GasConfig } from './types'
import { BadgeStatus, BadgeStatusReason, ErrorReason, GAS_MULTIPLIER, GasConfig } from './types'

function checksumAddresses(addresses: string[]): string[] {
return addresses.map((address) => ethers.utils.getAddress(address))
Expand Down Expand Up @@ -124,3 +126,51 @@ export async function createSpec(badgeCid: string) {
logger.log('Create badge spec with txn hash:', txn.hash)
return txn.hash
}

export async function getUsersWithoutBadge(badgeCid: string, users: string[]) {
const badges = await OtterspaceSubgraph.get().getBadges(badgeCid)
const usersWithBadgesToReinstate: string[] = []
const usersWithoutBadge: string[] = []

for (const user of users) {
const userBadge = badges.find((badge) => isSameAddress(user, badge.owner?.id))
if (!userBadge) {
usersWithoutBadge.push(user)
continue
}
if (userBadge.status === BadgeStatus.Revoked && userBadge.statusReason === BadgeStatusReason.TenureEnded) {
usersWithBadgesToReinstate.push(user)
}
}

return {
usersWithoutBadge,
usersWithBadgesToReinstate,
}
}

type ValidatedUsers = {
eligibleUsers: string[]
usersWithBadgesToReinstate: string[]
error?: string
}

export async function getValidatedUsersForBadge(badgeCid: string, addresses: string[]): Promise<ValidatedUsers> {
try {
const { usersWithoutBadge, usersWithBadgesToReinstate } = await getUsersWithoutBadge(badgeCid, addresses)
const usersWhoVoted = usersWithoutBadge.length > 0 ? await getUsersWhoVoted(usersWithoutBadge) : []
const result = {
eligibleUsers: usersWhoVoted,
usersWithBadgesToReinstate,
}
if (usersWithoutBadge.length === 0) {
return { ...result, error: ErrorReason.NoUserWithoutBadge }
}
if (usersWhoVoted.length === 0) {
return { ...result, error: ErrorReason.NoUserHasVoted }
}
return result
} catch (error) {
return { eligibleUsers: [], usersWithBadgesToReinstate: [], error: JSON.stringify(error) }
}
}
6 changes: 6 additions & 0 deletions src/entities/Snapshot/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logger from 'decentraland-gatsby/dist/entities/Development/logger'
import { ethers } from 'ethers'

import { SnapshotGraphql } from '../../clients/SnapshotGraphql'
import {
Delegation,
DelegationResult,
Expand Down Expand Up @@ -203,3 +204,8 @@ export function isSameAddress(userAddress?: string | null, address?: string | nu
getChecksumAddress(userAddress) === getChecksumAddress(address)
)
}

export async function getUsersWhoVoted(users: string[]) {
const votesFromUsers = await SnapshotGraphql.get().getAddressesVotes(users)
return Array.from(new Set(votesFromUsers.map((vote) => vote.voter.toLowerCase())))
}
6 changes: 6 additions & 0 deletions src/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,9 @@ export function getVestingContractUrl(address: string) {
const VESTING_DASHBOARD_URL = env('GATSBY_VESTING_DASHBOARD_URL')
return VESTING_DASHBOARD_URL.replace('%23', '#').concat(address.toLowerCase())
}

export function splitArray<Type>(array: Type[], chunkSize: number): Type[][] {
return Array.from({ length: Math.ceil(array.length / chunkSize) }, (_, index) =>
array.slice(index * chunkSize, (index + 1) * chunkSize)
)
}
89 changes: 35 additions & 54 deletions src/services/BadgesService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,37 @@ import { v1 as uuid } from 'uuid'

import AirdropJobModel, { AirdropJobStatus, AirdropOutcome } from '../back/models/AirdropJob'
import { OtterspaceBadge, OtterspaceSubgraph } from '../clients/OtterspaceSubgraph'
import { SnapshotGraphql } from '../clients/SnapshotGraphql'
import { LAND_OWNER_BADGE_SPEC_CID, LEGISLATOR_BADGE_SPEC_CID } from '../constants'
import { storeBadgeSpec } from '../entities/Badges/storeBadgeSpec'
import {
ActionResult,
ActionStatus,
Badge,
BadgeStatus,
BadgeStatusReason,
ErrorReason,
OtterspaceRevokeReason,
UserBadges,
toBadgeStatus,
} from '../entities/Badges/types'
import { airdrop, getLandOwnerAddresses, reinstateBadge, revokeBadge, trimOtterspaceId } from '../entities/Badges/utils'
import {
airdrop,
getLandOwnerAddresses,
getValidatedUsersForBadge,
reinstateBadge,
revokeBadge,
trimOtterspaceId,
} 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, isSameAddress } from '../entities/Snapshot/utils'
import { inBackground } from '../helpers'
import { getChecksumAddress } from '../entities/Snapshot/utils'
import { inBackground, splitArray } from '../helpers'
import { ErrorCategory } from '../utils/errorCategories'

import { ErrorService } from './ErrorService'

const TRANSACTION_UNDERPRICED_ERROR_CODE = -32000

enum ErrorReason {
NoUserWithoutBadge = 'All recipients already have this badge',
NoUserHasVoted = 'Recipients have never voted',
InvalidBadgeId = 'Invalid badge ID',
}

export class BadgesService {
public static async getBadges(address: string): Promise<UserBadges> {
const otterspaceBadges: OtterspaceBadge[] = await OtterspaceSubgraph.get().getBadgesForAddress(address)
Expand Down Expand Up @@ -85,53 +85,23 @@ export class BadgesService {

public static async giveBadgeToUsers(badgeCid: string, users: string[]): Promise<AirdropOutcome> {
try {
const { usersWithoutBadge, usersWithBadgesToReinstate } = await this.getUsersWithoutBadge(badgeCid, users)
const { eligibleUsers, usersWithBadgesToReinstate, error } = await getValidatedUsersForBadge(badgeCid, users)
if (usersWithBadgesToReinstate.length > 0) {
inBackground(async () => {
return await this.reinstateBadge(badgeCid, usersWithBadgesToReinstate)
})
}

if (usersWithoutBadge.length === 0) {
return { status: AirdropJobStatus.FAILED, error: ErrorReason.NoUserWithoutBadge }
if (error) {
return { status: AirdropJobStatus.FAILED, error }
}
const usersWhoVoted = await this.getUsersWhoVoted(usersWithoutBadge)
if (usersWhoVoted.length === 0) {
return { status: AirdropJobStatus.FAILED, error: ErrorReason.NoUserHasVoted }
}
return await this.airdropWithRetry(badgeCid, usersWhoVoted)

return await this.airdropWithRetry(badgeCid, eligibleUsers)
} catch (e) {
return { status: AirdropJobStatus.FAILED, error: JSON.stringify(e) }
}
}

private static async getUsersWhoVoted(usersWithoutBadge: string[]) {
const votesFromUsers = await SnapshotGraphql.get().getAddressesVotes(usersWithoutBadge)
return Array.from(new Set(votesFromUsers.map((vote) => vote.voter.toLowerCase())))
}

private static async getUsersWithoutBadge(badgeCid: string, users: string[]) {
const badges = await OtterspaceSubgraph.get().getBadges(badgeCid)
const usersWithBadgesToReinstate: string[] = []
const usersWithoutBadge: string[] = []

for (const user of users) {
const userBadge = badges.find((badge) => isSameAddress(user, badge.owner?.id))
if (!userBadge) {
usersWithoutBadge.push(user)
continue
}
if (userBadge.status === BadgeStatus.Revoked && userBadge.statusReason === BadgeStatusReason.TenureEnded) {
usersWithBadgesToReinstate.push(user)
}
}

return {
usersWithoutBadge,
usersWithBadgesToReinstate,
}
}

private static async airdropWithRetry(
badgeCid: string,
recipients: string[],
Expand Down Expand Up @@ -173,17 +143,28 @@ export class BadgesService {

static async giveAndRevokeLandOwnerBadges() {
const landOwnerAddresses = await getLandOwnerAddresses()
const { status, error } = await BadgesService.giveBadgeToUsers(LAND_OWNER_BADGE_SPEC_CID, landOwnerAddresses)
if (
status === AirdropJobStatus.FAILED &&
error !== ErrorReason.NoUserWithoutBadge &&
error !== ErrorReason.NoUserHasVoted
) {
console.error('Unable to give LandOwner badges', error)
const { eligibleUsers, usersWithBadgesToReinstate } = await getValidatedUsersForBadge(
LAND_OWNER_BADGE_SPEC_CID,
landOwnerAddresses
)

const outcomes = await Promise.all(
splitArray([...eligibleUsers, ...usersWithBadgesToReinstate], 50).map((addresses) =>
BadgesService.giveBadgeToUsers(LAND_OWNER_BADGE_SPEC_CID, addresses)
)
)
const failedOutcomes = outcomes.filter(
({ status, error }) =>
status === AirdropJobStatus.FAILED &&
error !== ErrorReason.NoUserWithoutBadge &&
error !== ErrorReason.NoUserHasVoted
)
if (failedOutcomes.length > 0) {
console.error('Unable to give LandOwner badges', failedOutcomes)

ErrorService.report('Unable to give LandOwner badges', {
category: ErrorCategory.Badges,
error,
failedOutcomes,
})
}

Expand Down
Loading