Skip to content

Commit

Permalink
fix: Badge airdrops in batches (#1223)
Browse files Browse the repository at this point in the history
* fix: airdrop in batches

* airdrop in batches simplified

* validation extracted

* prod env check replaced

* minor fix
  • Loading branch information
ncomerci committed Aug 30, 2023
1 parent 9ec500f commit bd4b494
Show file tree
Hide file tree
Showing 6 changed files with 108 additions and 56 deletions.
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

0 comments on commit bd4b494

Please sign in to comment.