Skip to content

Commit

Permalink
chore: revoke badges (#1191)
Browse files Browse the repository at this point in the history
* chore: revoke a badge WIP

* chore: revoke badges

* refactor: address pr comments
  • Loading branch information
1emu committed Aug 16, 2023
1 parent 7dc13c0 commit 9964b83
Show file tree
Hide file tree
Showing 8 changed files with 278 additions and 41 deletions.
41 changes: 38 additions & 3 deletions src/back/routes/badges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import handleAPI from 'decentraland-gatsby/dist/entities/Route/handle'
import routes from 'decentraland-gatsby/dist/entities/Route/routes'
import { Request } from 'express'

import { UserBadges } from '../../entities/Badges/types'
import { UserBadges, toOtterspaceRevokeReason } from '../../entities/Badges/types'
import isDebugAddress from '../../entities/Debug/isDebugAddress'
import { BadgesService } from '../../services/BadgesService'
import { AirdropOutcome } from '../models/AirdropJob'
Expand All @@ -14,6 +14,7 @@ export default routes((router) => {
const withAuth = auth()
router.get('/badges/:address/', handleAPI(getBadges))
router.post('/badges/airdrop/', withAuth, handleAPI(airdropBadges))
router.post('/badges/revoke/', withAuth, handleAPI(revokeBadge))
})

async function getBadges(req: Request<{ address: string }>): Promise<UserBadges> {
Expand All @@ -25,7 +26,7 @@ async function getBadges(req: Request<{ address: string }>): Promise<UserBadges>
async function airdropBadges(req: WithAuth): Promise<AirdropOutcome> {
const user = req.auth!
const recipients: string[] = req.body.recipients
const badgeSpecCId = req.body.badgeSpecCid
const badgeSpecCid = req.body.badgeSpecCid

if (!isDebugAddress(user)) {
throw new RequestError('Invalid user', RequestError.Unauthorized)
Expand All @@ -35,5 +36,39 @@ async function airdropBadges(req: WithAuth): Promise<AirdropOutcome> {
validateAddress(address)
})

return await BadgesService.giveBadgeToUsers(badgeSpecCId, recipients)
if (!badgeSpecCid || badgeSpecCid.length === 0) {
throw new RequestError('Invalid Badge Spec Cid', RequestError.BadRequest)
}

return await BadgesService.giveBadgeToUsers(badgeSpecCid, recipients)
}

async function revokeBadge(req: WithAuth): Promise<string> {
const user = req.auth!
const { badgeSpecCid, reason } = req.body
const recipients: string[] = req.body.recipients

if (!isDebugAddress(user)) {
throw new RequestError('Invalid user', RequestError.Unauthorized)
}

recipients.map((address) => {
validateAddress(address)
})

if (!badgeSpecCid || badgeSpecCid.length === 0) {
throw new RequestError('Invalid Badge Spec Cid', RequestError.BadRequest)
}

const validatedReason = reason
? toOtterspaceRevokeReason(reason, (reason) => {
throw new RequestError(`Invalid revoke reason ${reason}`, RequestError.BadRequest)
})
: undefined
try {
const revocationResults = await BadgesService.revokeBadge(badgeSpecCid, recipients, validatedReason)
return `Revocation results: ${JSON.stringify(revocationResults)}`
} catch (e) {
return `Failed to revoke badges ${JSON.stringify(e)}`
}
}
24 changes: 14 additions & 10 deletions src/clients/Governance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,21 +557,25 @@ export class Governance extends API {
}

async airdropBadge(badgeSpecCid: string, recipients: string[]) {
const data = {
badgeSpecCid,
recipients,
}
const response = await this.fetch<ApiResponse<AirdropOutcome>>(
`/badges/airdrop/`,
this.options().method('POST').authorization({ sign: true }).json(data)
this.options().method('POST').authorization({ sign: true }).json({
badgeSpecCid,
recipients,
})
)
return response.data
}

//TODO: implement and test what happens if airdropping to a user with revoked badge
async revokeBadge(badgeSpecCid: string, recipients: string[]) {
console.log('badgeSpecCid', badgeSpecCid)
console.log('recipients', recipients)
return `Revoke ${badgeSpecCid} from ${recipients}`
async revokeBadge(badgeSpecCid: string, recipients: string[], reason?: string) {
const response = await this.fetch<ApiResponse<string>>(
`/badges/revoke/`,
this.options().method('POST').authorization({ sign: true }).json({
badgeSpecCid,
recipients,
reason,
})
)
return response.data
}
}
44 changes: 44 additions & 0 deletions src/clients/OtterspaceSubgraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,23 @@ query Badges($badgeCid: String!) {
}
}`

const RECIPIENTS_BADGE_ID_QUERY = `
query Badges($badgeCid: String!, $addresses: [String]!, $first: Int!, $skip: Int!) {
badges: badges(
where:{
spec: $badgeCid,
owner_in: $addresses
},
first: $first, skip: $skip,
) {
id
owner {
id
}
}
}
`

export type OtterspaceBadge = {
id: string
createdAt: number
Expand All @@ -72,6 +89,8 @@ export type OtterspaceBadge = {
}
}

type BadgeOwnership = { id: string; address: string }

export class OtterspaceSubgraph {
static Cache = new Map<string, OtterspaceSubgraph>()
private readonly queryEndpoint: string
Expand Down Expand Up @@ -148,4 +167,29 @@ export class OtterspaceSubgraph {

return badges.map((badge) => badge.owner?.id.toLowerCase()).filter(Boolean)
}

async getRecipientsBadgeIds(badgeCid: string, addresses: string[]): Promise<BadgeOwnership[]> {
const badges: Pick<OtterspaceBadge, 'id' | 'owner'>[] = await inBatches(
async (vars, skip, first) => {
const response = await fetch(this.queryEndpoint, {
method: 'post',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: RECIPIENTS_BADGE_ID_QUERY,
variables: { ...vars, skip, first },
operationName: 'Badges',
extensions: { headers: null },
}),
})

const body = await response.json()
return body?.data?.badges || []
},
{ badgeCid, addresses }
)

return badges.map((badge) => {
return { id: badge.id, address: badge.owner?.id || '' }
})
}
}
64 changes: 49 additions & 15 deletions src/components/Debug/BadgesAdmin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import React, { useState } from 'react'

import { Button } from 'decentraland-ui/dist/components/Button/Button'
import { Field } from 'decentraland-ui/dist/components/Field/Field'
import { SelectField } from 'decentraland-ui/dist/components/SelectField/SelectField'

import { Governance } from '../../clients/Governance'
import { OtterspaceRevokeReason } from '../../entities/Badges/types'
import AddressesSelect from '../AddressSelect/AddressesSelect'
import Heading from '../Common/Typography/Heading'
import Label from '../Common/Typography/Label'
Expand All @@ -15,11 +17,31 @@ interface Props {
className?: string
}

const REVOKE_REASON_OPTIONS = [
{
text: 'Abuse',
value: OtterspaceRevokeReason.Abuse,
},
{
text: 'Left Community',
value: OtterspaceRevokeReason.LeftCommunity,
},
{
text: 'Tenure Ended',
value: OtterspaceRevokeReason.TenureEnded,
},
{
text: 'Other',
value: OtterspaceRevokeReason.Other,
},
]

export default function BadgesAdmin({ className }: Props) {
const [recipients, setRecipients] = useState<string[] | undefined>([])
const [recipients, setRecipients] = useState<string[]>([])
const [badgeCid, setBadgeCid] = useState<string | undefined>()
const [reason, setReason] = useState<string>(OtterspaceRevokeReason.TenureEnded)
const [result, setResult] = useState<string | null>()
const [errorMessage, setErrorMessage] = useState<any>()
const [errorMessage, setErrorMessage] = useState<string | undefined | null>()
const [formDisabled, setFormDisabled] = useState(false)

async function handleAirdropBadge() {
Expand All @@ -34,7 +56,7 @@ export default function BadgesAdmin({ className }: Props) {
async function handleRevokeBadge() {
if (badgeCid && recipients) {
await submit(
async () => Governance.get().revokeBadge(badgeCid, recipients),
async () => Governance.get().revokeBadge(badgeCid, recipients, reason),
(result) => setResult(result)
)
}
Expand Down Expand Up @@ -63,28 +85,40 @@ export default function BadgesAdmin({ className }: Props) {
<ContentSection>
<Heading size="sm">{'Badges'}</Heading>
<div>
<Button className="Debug__SectionButton" primary disabled={formDisabled} onClick={() => handleAirdropBadge()}>
{'Airdrop'}
</Button>
<Button className="Debug__SideButton" primary disabled={formDisabled} onClick={() => handleRevokeBadge()}>
{'Revoke'}
</Button>
</div>
<Label>{'Badge Spec Cid'}</Label>
<Field value={badgeCid} onChange={(_, { value }) => setBadgeCid(value)} />
<div>
<div>
<Button
className="Debug__SectionButton"
primary
disabled={formDisabled}
onClick={() => handleAirdropBadge()}
>
{'Airdrop'}
</Button>
<Button className="Debug__SideButton" primary disabled={formDisabled} onClick={() => handleRevokeBadge()}>
{'Revoke'}
</Button>
</div>
<Label>{'Badge Spec Cid'}</Label>
<Field value={badgeCid} onChange={(_, { value }) => setBadgeCid(value)} />
<Label>{'Recipients'}</Label>
<AddressesSelect
setUsersAddresses={(addresses?: string[]) => setRecipients(addresses)}
setUsersAddresses={(addresses?: string[]) => setRecipients(addresses || [])}
isDisabled={formDisabled}
maxAddressesAmount={20}
allowLoggedUserAccount={true}
/>
<Label>{'Revoke Reason'}</Label>
<SelectField
value={reason}
onChange={(_, { value }) => setReason(value as string)}
options={REVOKE_REASON_OPTIONS}
disabled={formDisabled}
/>
</div>
{result && (
<>
<Label>{'Result'}</Label>
<Text>{result}</Text>
<Text className="Debug__Result">{result}</Text>
</>
)}
</ContentSection>
Expand Down
37 changes: 37 additions & 0 deletions src/entities/Badges/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ethers } from 'ethers'

export enum BadgeStatus {
Burned = 'BURNED',
Minted = 'MINTED',
Expand All @@ -21,6 +23,22 @@ export type Badge = {

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

export enum OtterspaceRevokeReason {
Abuse = '0',
LeftCommunity = '1',
TenureEnded = '2',
Other = '3',
}

export enum RevocationStatus {
Failed = 'Failed',
Success = 'Success',
}

export type RevocationResult = { status: RevocationStatus; address: string; badgeId: string; error?: string }
export const GAS_MULTIPLIER = 2
export type GasConfig = { gasPrice: ethers.BigNumber; gasLimit: ethers.BigNumber }

export function isBadgeStatus(value: string | null | undefined): boolean {
return !!value && new Set<string>(Object.values(BadgeStatus)).has(value)
}
Expand All @@ -29,3 +47,22 @@ export function toBadgeStatus(value: string | null | undefined): BadgeStatus {
if (isBadgeStatus(value)) return value as BadgeStatus
else throw new Error(`Invalid BadgeStatus ${value}`)
}

export function isOtterspaceRevokeReason(value: string | null | undefined): boolean {
switch (value) {
case OtterspaceRevokeReason.Abuse:
case OtterspaceRevokeReason.LeftCommunity:
case OtterspaceRevokeReason.TenureEnded:
case OtterspaceRevokeReason.Other:
return true
default:
return false
}
}

export function toOtterspaceRevokeReason(
value: string | null | undefined,
orElse: (value: string | null | undefined) => never
): OtterspaceRevokeReason {
return isOtterspaceRevokeReason(value) ? (value as OtterspaceRevokeReason) : orElse(value)
}
Loading

0 comments on commit 9964b83

Please sign in to comment.