From bd4b494567285544fad9441b4441089562dfc71f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Comerci?= <45410089+ncomerci@users.noreply.github.com> Date: Wed, 30 Aug 2023 16:51:57 -0300 Subject: [PATCH] fix: Badge airdrops in batches (#1223) * fix: airdrop in batches * airdrop in batches simplified * validation extracted * prod env check replaced * minor fix --- src/back/jobs/BadgeAirdrop.ts | 5 +- src/entities/Badges/types.ts | 6 +++ src/entities/Badges/utils.ts | 52 +++++++++++++++++++- src/entities/Snapshot/utils.ts | 6 +++ src/helpers/index.ts | 6 +++ src/services/BadgesService.ts | 89 +++++++++++++--------------------- 6 files changed, 108 insertions(+), 56 deletions(-) diff --git a/src/back/jobs/BadgeAirdrop.ts b/src/back/jobs/BadgeAirdrop.ts index 512edea81..3b704a016 100644 --- a/src/back/jobs/BadgeAirdrop.ts +++ b/src/back/jobs/BadgeAirdrop.ts @@ -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() { @@ -28,5 +29,7 @@ async function runQueuedAirdropJobs() { } async function giveAndRevokeLandOwnerBadges() { - await BadgesService.giveAndRevokeLandOwnerBadges() + if (isProdEnv()) { + await BadgesService.giveAndRevokeLandOwnerBadges() + } } diff --git a/src/entities/Badges/types.ts b/src/entities/Badges/types.ts index 013dcfd4d..3da8e8c1a 100644 --- a/src/entities/Badges/types.ts +++ b/src/entities/Badges/types.ts @@ -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 { diff --git a/src/entities/Badges/utils.ts b/src/entities/Badges/utils.ts index 58f6979dc..38b91ddea 100644 --- a/src/entities/Badges/utils.ts +++ b/src/entities/Badges/utils.ts @@ -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)) @@ -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 { + 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) } + } +} diff --git a/src/entities/Snapshot/utils.ts b/src/entities/Snapshot/utils.ts index 6e3bc7502..59bdae85b 100644 --- a/src/entities/Snapshot/utils.ts +++ b/src/entities/Snapshot/utils.ts @@ -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, @@ -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()))) +} diff --git a/src/helpers/index.ts b/src/helpers/index.ts index e89501d1e..30a042002 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -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(array: Type[], chunkSize: number): Type[][] { + return Array.from({ length: Math.ceil(array.length / chunkSize) }, (_, index) => + array.slice(index * chunkSize, (index + 1) * chunkSize) + ) +} diff --git a/src/services/BadgesService.ts b/src/services/BadgesService.ts index f4979aa08..6b41d74b6 100644 --- a/src/services/BadgesService.ts +++ b/src/services/BadgesService.ts @@ -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 { const otterspaceBadges: OtterspaceBadge[] = await OtterspaceSubgraph.get().getBadgesForAddress(address) @@ -85,53 +85,23 @@ export class BadgesService { public static async giveBadgeToUsers(badgeCid: string, users: string[]): Promise { 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[], @@ -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, }) }