Skip to content

Commit

Permalink
refactor: optimize user classification for badge actions, reuse signe…
Browse files Browse the repository at this point in the history
…r and contract for gas estimation and badge txs (#1866)
  • Loading branch information
1emu committed Jun 24, 2024
1 parent 5a08ccc commit 5818af8
Show file tree
Hide file tree
Showing 5 changed files with 216 additions and 68 deletions.
2 changes: 1 addition & 1 deletion src/back/utils/contractInteractions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
97 changes: 65 additions & 32 deletions src/entities/Badges/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set(users)
const listedUsersWithRevokedBadge: Set<string> = new Set()
const listedUsersWithBurnedBadge: Set<string> = new Set()
const listedUsersWithMintedOrReinstatedBadge: Set<string> = new Set()
const unlistedUsersWithRevokedOrBurnedBadge: Set<string> = new Set()
const unlistedUsersWithMintedOrReinstatedBadge: Set<string> = 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<ValidatedUsers> {
export async function getEligibleUsersForBadge(
badgeCid: string,
addresses: string[]
): Promise<ClassifiedUsersForBadgeAction> {
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),
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/entities/Snapshot/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
}
11 changes: 9 additions & 2 deletions src/services/BadgesService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
172 changes: 140 additions & 32 deletions src/services/BadgesService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -17,7 +25,6 @@ import {
ActionStatus,
Badge,
BadgeCreationResult,
BadgeStatus,
ErrorReason,
GovernanceBadgeSpec,
OtterspaceRevokeReason,
Expand All @@ -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'
Expand Down Expand Up @@ -97,18 +104,28 @@ export class BadgesService {

public static async giveBadgeToUsers(badgeCid: string, users: string[]): Promise<AirdropOutcome> {
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 }
}
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -272,6 +289,97 @@ export class BadgesService {
})
}

static async revokeBadges(
badgeCid: string,
addresses: string[],
reason = OtterspaceRevokeReason.TenureEnded
): Promise<RevokeOrReinstateResult[]> {
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<RevokeOrReinstateResult[]> {
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)
Expand Down

0 comments on commit 5818af8

Please sign in to comment.