diff --git a/src/back/utils/contractInteractions.ts b/src/back/utils/contractInteractions.ts index b771fbe4a..fd1a15033 100644 --- a/src/back/utils/contractInteractions.ts +++ b/src/back/utils/contractInteractions.ts @@ -17,7 +17,7 @@ function checksumAddresses(addresses: string[]): string[] { return addresses.map((address) => ethers.utils.getAddress(address)) } -function getBadgesSignerAndContract() { +export 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) diff --git a/src/entities/Badges/utils.ts b/src/entities/Badges/utils.ts index e4b10ab65..5bcc80793 100644 --- a/src/entities/Badges/utils.ts +++ b/src/entities/Badges/utils.ts @@ -6,57 +6,90 @@ 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 { getUsersWhoVoted } from '../Snapshot/utils' -import { BadgeStatus, BadgeStatusReason, ErrorReason } from './types' +import { BadgeStatus } from './types' const TOP_VOTER_TITLE_PREFIX = `Top Voter` -export async function getUsersWithoutBadge(badgeCid: string, users: string[]) { +export async function getClassifiedUsersForBadge(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) + const listedUsers = new Set(users) + + const listedUsersWithoutBadge: Set = new Set(users) + const listedUsersWithRevokedBadge: Set = new Set() + const listedUsersWithBurnedBadge: Set = new Set() + const listedUsersWithMintedOrReinstatedBadge: Set = new Set() + const unlistedUsersWithRevokedOrBurnedBadge: Set = new Set() + const unlistedUsersWithMintedOrReinstatedBadge: Set = new Set() + + for (const badge of badges) { + const owner = badge.owner?.id.toLowerCase() + if (owner) { + if (listedUsers.has(owner)) { + listedUsersWithoutBadge.delete(owner) + if (badge.status === BadgeStatus.Revoked) { + listedUsersWithRevokedBadge.add(owner) + } else if (badge.status === BadgeStatus.Burned) { + listedUsersWithBurnedBadge.add(owner) + } else if (badge.status === BadgeStatus.Minted || badge.status === BadgeStatus.Reinstated) { + listedUsersWithMintedOrReinstatedBadge.add(owner) + } + } else { + if (badge.status === BadgeStatus.Revoked || badge.status === BadgeStatus.Burned) { + unlistedUsersWithRevokedOrBurnedBadge.add(owner) + } else if (badge.status === BadgeStatus.Minted || badge.status === BadgeStatus.Reinstated) { + unlistedUsersWithMintedOrReinstatedBadge.add(owner) + } + } } } return { - usersWithoutBadge, - usersWithBadgesToReinstate, + listedUsersWithoutBadge: Array.from(listedUsersWithoutBadge), + listedUsersWithRevokedBadge: Array.from(listedUsersWithRevokedBadge), + listedUsersWithBurnedBadge: Array.from(listedUsersWithBurnedBadge), + listedUsersWithMintedOrReinstatedBadge: Array.from(listedUsersWithMintedOrReinstatedBadge), + unlistedUsersWithRevokedOrBurnedBadge: Array.from(unlistedUsersWithRevokedOrBurnedBadge), + unlistedUsersWithMintedOrReinstatedBadge: Array.from(unlistedUsersWithMintedOrReinstatedBadge), } } -type ValidatedUsers = { - eligibleUsers: string[] +type ClassifiedUsersForBadgeAction = { + eligibleUsersForBadge: string[] usersWithBadgesToReinstate: string[] + usersWithBadgesToRevoke: string[] error?: string } -export async function getValidatedUsersForBadge(badgeCid: string, addresses: string[]): Promise { +export async function getEligibleUsersForBadge( + 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 } + const { listedUsersWithoutBadge, listedUsersWithRevokedBadge, unlistedUsersWithMintedOrReinstatedBadge } = + await getClassifiedUsersForBadge(badgeCid, addresses) + + const usersToCheck = [...listedUsersWithoutBadge, ...listedUsersWithRevokedBadge] + const usersWhoVoted = usersToCheck.length > 0 ? await getUsersWhoVoted(usersToCheck) : new Set() + + const listedUsersWithoutBadgeWhoVoted = Array.from(listedUsersWithoutBadge).filter((user) => + usersWhoVoted.has(user) + ) + const usersWithBadgesToReinstate = Array.from(listedUsersWithRevokedBadge).filter((user) => usersWhoVoted.has(user)) + + return { + eligibleUsersForBadge: listedUsersWithoutBadgeWhoVoted, + usersWithBadgesToReinstate: usersWithBadgesToReinstate, + usersWithBadgesToRevoke: unlistedUsersWithMintedOrReinstatedBadge, } - return result } catch (error) { - return { eligibleUsers: [], usersWithBadgesToReinstate: [], error: JSON.stringify(error) } + return { + eligibleUsersForBadge: [], + usersWithBadgesToReinstate: [], + usersWithBadgesToRevoke: [], + error: JSON.stringify(error), + } } } diff --git a/src/entities/Snapshot/utils.ts b/src/entities/Snapshot/utils.ts index 904265601..9d7ec9325 100644 --- a/src/entities/Snapshot/utils.ts +++ b/src/entities/Snapshot/utils.ts @@ -207,5 +207,5 @@ export function isSameAddress(userAddress?: string | null, address?: string | nu export async function getUsersWhoVoted(users: string[]) { const votesFromUsers = await SnapshotGraphql.get().getVotesByAddresses(users) - return Array.from(new Set(votesFromUsers.map((vote) => vote.voter.toLowerCase()))) + return new Set(votesFromUsers.map((vote) => vote.voter.toLowerCase())) } diff --git a/src/services/BadgesService.test.ts b/src/services/BadgesService.test.ts index bb43d3f4a..e232f30b7 100644 --- a/src/services/BadgesService.test.ts +++ b/src/services/BadgesService.test.ts @@ -16,8 +16,15 @@ describe('giveLegislatorBadges', () => { it('should call queueAirdropJob with correct arguments for governance proposals', async () => { jest.spyOn(AirdropJobModel, 'create').mockResolvedValue(async () => {}) jest.spyOn(CoauthorModel, 'findAllByProposals').mockResolvedValue(COAUTHORS) - jest.spyOn(BadgesUtils, 'getUsersWithoutBadge').mockImplementation((badgeCid: string, users: string[]) => { - return Promise.resolve({ usersWithoutBadge: users, usersWithBadgesToReinstate: [] }) + jest.spyOn(BadgesUtils, 'getClassifiedUsersForBadge').mockImplementation((badgeCid: string, users: string[]) => { + return Promise.resolve({ + listedUsersWithoutBadge: users, + listedUsersWithMintedOrReinstatedBadge: [], + listedUsersWithRevokedBadge: [], + listedUsersWithBurnedBadge: [], + unlistedUsersWithMintedOrReinstatedBadge: [], + unlistedUsersWithRevokedOrBurnedBadge: [], + }) }) const proposal = createTestProposal(ProposalType.Governance, ProposalStatus.Passed) const expectedAuthorsAndCoauthors = [proposal.user, ...COAUTHORS].map(getChecksumAddress) diff --git a/src/services/BadgesService.ts b/src/services/BadgesService.ts index 52f2008eb..d37965c19 100644 --- a/src/services/BadgesService.ts +++ b/src/services/BadgesService.ts @@ -3,12 +3,20 @@ import crypto from 'crypto' import AirdropJobModel from '../back/models/AirdropJob' import { VoteService } from '../back/services/vote' import { AirdropJobStatus, AirdropOutcome } from '../back/types/AirdropJob' -import { airdropWithRetry, createSpecWithRetry, reinstateBadge, revokeBadge } from '../back/utils/contractInteractions' +import { + airdropWithRetry, + createSpecWithRetry, + estimateGas, + getBadgesSignerAndContract, + reinstateBadge, + revokeBadge, +} from '../back/utils/contractInteractions' import { OtterspaceBadge, OtterspaceSubgraph } from '../clients/OtterspaceSubgraph' import { LAND_OWNER_BADGE_SPEC_CID, LEGISLATOR_BADGE_SPEC_CID, TOP_VOTERS_PER_MONTH, + TRIMMED_OTTERSPACE_RAFT_ID, trimOtterspaceId, } from '../constants' import { UPLOADED_BADGES } from '../entities/Badges/constants' @@ -17,7 +25,6 @@ import { ActionStatus, Badge, BadgeCreationResult, - BadgeStatus, ErrorReason, GovernanceBadgeSpec, OtterspaceRevokeReason, @@ -29,12 +36,12 @@ import { toBadgeStatusReason, } from '../entities/Badges/types' import { + getClassifiedUsersForBadge, + getEligibleUsersForBadge, getGithubBadgeImageUrl, getIpfsHttpsLink, getLandOwnerAddresses, getTopVotersBadgeSpec, - getUsersWithoutBadge, - getValidatedUsersForBadge, isSpecAlreadyCreated, } from '../entities/Badges/utils' import CoauthorModel from '../entities/Coauthor/model' @@ -97,18 +104,28 @@ export class BadgesService { public static async giveBadgeToUsers(badgeCid: string, users: string[]): Promise { try { - const { eligibleUsers, usersWithBadgesToReinstate, error } = await getValidatedUsersForBadge(badgeCid, users) + const { eligibleUsersForBadge, usersWithBadgesToReinstate, error } = await getEligibleUsersForBadge( + badgeCid, + users + ) + if (error) { + return { status: AirdropJobStatus.FAILED, error, recipients: users, badge_spec: badgeCid } + } if (usersWithBadgesToReinstate.length > 0) { inBackground(async () => { return await this.reinstateBadge(badgeCid, usersWithBadgesToReinstate) }) } - - if (error) { - return { status: AirdropJobStatus.FAILED, error, recipients: users, badge_spec: badgeCid } + if (eligibleUsersForBadge.length > 0) { + return await airdropWithRetry(badgeCid, eligibleUsersForBadge) } - return await airdropWithRetry(badgeCid, eligibleUsers) + return { + status: AirdropJobStatus.FINISHED, + error: 'No eligible recipients', + recipients: users, + badge_spec: badgeCid, + } } catch (e) { return { status: AirdropJobStatus.FAILED, error: JSON.stringify(e), recipients: users, badge_spec: badgeCid } } @@ -130,34 +147,34 @@ export class BadgesService { }) return } - const { usersWithoutBadge } = await getUsersWithoutBadge(LEGISLATOR_BADGE_SPEC_CID, recipients) - await this.queueAirdropJob(LEGISLATOR_BADGE_SPEC_CID, usersWithoutBadge) + const { listedUsersWithoutBadge } = await getClassifiedUsersForBadge(LEGISLATOR_BADGE_SPEC_CID, recipients) + await this.queueAirdropJob(LEGISLATOR_BADGE_SPEC_CID, listedUsersWithoutBadge) } static async giveAndRevokeLandOwnerBadges() { const landOwnerAddresses = await getLandOwnerAddresses() - const { eligibleUsers, usersWithBadgesToReinstate } = await getValidatedUsersForBadge( - LAND_OWNER_BADGE_SPEC_CID, - landOwnerAddresses - ) - - await this.giveLandOwnerBadges(eligibleUsers) - await this.reinstateLandOwnerBadges(usersWithBadgesToReinstate) - await this.revokeLandOwnerBadges(landOwnerAddresses) + const { eligibleUsersForBadge, usersWithBadgesToReinstate, usersWithBadgesToRevoke, error } = + await getEligibleUsersForBadge(LAND_OWNER_BADGE_SPEC_CID, landOwnerAddresses) + if (error) { + ErrorService.report('Unable to get eligible users for LandOwner badge', { + category: ErrorCategory.Badges, + error, + }) + return + } + if (eligibleUsersForBadge.length > 0) { + await this.giveLandOwnerBadges(eligibleUsersForBadge) + } + if (usersWithBadgesToReinstate.length > 0) { + await this.reinstateLandOwnerBadges(usersWithBadgesToReinstate) + } + if (usersWithBadgesToRevoke.length > 0) { + await this.revokeLandOwnerBadges(usersWithBadgesToRevoke) + } } - private static async revokeLandOwnerBadges(landOwnerAddresses: string[]) { - const badges = await OtterspaceSubgraph.get().getBadges(LAND_OWNER_BADGE_SPEC_CID) - const landOwnerAddressesSet = new Set(landOwnerAddresses) - const addressesToRevoke = badges - .filter( - (badge) => - (badge.status === BadgeStatus.Minted || badge.status === BadgeStatus.Reinstated) && - !landOwnerAddressesSet.has(badge.owner?.id?.toLowerCase() || '') - ) - .map((badge) => badge.owner!.id) - - const revocationResults = await BadgesService.revokeBadge(LAND_OWNER_BADGE_SPEC_CID, addressesToRevoke) + private static async revokeLandOwnerBadges(addressesToRevoke: string[]) { + const revocationResults = await BadgesService.revokeBadges(LAND_OWNER_BADGE_SPEC_CID, addressesToRevoke) const failedRevocations = revocationResults.filter((result) => result.status === ActionStatus.Failed) if (failedRevocations.length > 0) { console.error('Unable to revoke LandOwner badges', failedRevocations) @@ -169,7 +186,7 @@ export class BadgesService { } private static async reinstateLandOwnerBadges(usersWithBadgesToReinstate: string[]) { - const reinstateResults = await BadgesService.reinstateBadge(LAND_OWNER_BADGE_SPEC_CID, usersWithBadgesToReinstate) + const reinstateResults = await BadgesService.reinstateBadges(LAND_OWNER_BADGE_SPEC_CID, usersWithBadgesToReinstate) const failedReinstatements = reinstateResults.filter((result) => result.status === ActionStatus.Failed) if (failedReinstatements.length > 0) { console.error('Unable to reinstate LandOwner badges', failedReinstatements) @@ -272,6 +289,97 @@ export class BadgesService { }) } + static async revokeBadges( + badgeCid: string, + addresses: string[], + reason = OtterspaceRevokeReason.TenureEnded + ): Promise { + const badgeOwnerships = await OtterspaceSubgraph.get().getRecipientsBadgeIds(badgeCid, addresses) + if (!badgeOwnerships || badgeOwnerships.length === 0) { + return [] + } + const { signer, contract } = getBadgesSignerAndContract() + const gasConfig = await estimateGas(async () => { + return contract.estimateGas.revokeBadge( + TRIMMED_OTTERSPACE_RAFT_ID, + trimOtterspaceId(badgeOwnerships[0].id), + reason + ) + }) + + const actionResults: RevokeOrReinstateResult[] = [] + for (const badgeOwnership of badgeOwnerships) { + const trimmedId = trimOtterspaceId(badgeOwnership.id) + try { + if (trimmedId === '') { + actionResults.push({ + status: ActionStatus.Failed, + address: badgeOwnership.address, + badgeId: badgeOwnership.id, + error: ErrorReason.InvalidBadgeId, + }) + } + const txn = await contract.connect(signer).revokeBadge(TRIMMED_OTTERSPACE_RAFT_ID, trimmedId, reason, gasConfig) + await txn.wait() + logger.log('Revoked badge with txn hash:', txn.hash) + actionResults.push({ status: ActionStatus.Success, address: badgeOwnership.address, badgeId: trimmedId }) + /* eslint-disable @typescript-eslint/no-explicit-any */ + } catch (error: any) { + logger.error('Failed to revoke badge:', error) + actionResults.push({ + status: ActionStatus.Failed, + address: badgeOwnership.address, + badgeId: trimmedId, + error: JSON.stringify(error?.message || error?.reason || error), + }) + } + } + + return actionResults + } + + static async reinstateBadges(badgeCid: string, addresses: string[]): Promise { + const badgeOwnerships = await OtterspaceSubgraph.get().getRecipientsBadgeIds(badgeCid, addresses) + if (!badgeOwnerships || badgeOwnerships.length === 0) { + return [] + } + const { signer, contract } = getBadgesSignerAndContract() + const gasConfig = await estimateGas(async () => { + return contract.estimateGas.reinstateBadge(TRIMMED_OTTERSPACE_RAFT_ID, trimOtterspaceId(badgeOwnerships[0].id)) + }) + + const actionResults: RevokeOrReinstateResult[] = [] + + for (const badgeOwnership of badgeOwnerships) { + const trimmedId = trimOtterspaceId(badgeOwnership.id) + try { + if (trimmedId === '') { + actionResults.push({ + status: ActionStatus.Failed, + address: badgeOwnership.address, + badgeId: badgeOwnership.id, + error: ErrorReason.InvalidBadgeId, + }) + } + const txn = await contract.connect(signer).reinstateBadge(TRIMMED_OTTERSPACE_RAFT_ID, trimmedId, gasConfig) + await txn.wait() + logger.log('Reinstated badge with txn hash:', txn.hash) + actionResults.push({ status: ActionStatus.Success, address: badgeOwnership.address, badgeId: trimmedId }) + /* eslint-disable @typescript-eslint/no-explicit-any */ + } catch (error: any) { + logger.error('Failed to reinstate badge:', error) + actionResults.push({ + status: ActionStatus.Failed, + address: badgeOwnership.address, + badgeId: trimmedId, + error: JSON.stringify(error?.message || error?.reason || error), + }) + } + } + + return actionResults + } + static async reinstateBadge(badgeCid: string, addresses: string[]) { return await this.performBadgeAction(badgeCid, addresses, async (badgeId) => { await reinstateBadge(badgeId)