diff --git a/src/back/routes/badges.ts b/src/back/routes/badges.ts index aae2b8f5b..49bb19c8f 100644 --- a/src/back/routes/badges.ts +++ b/src/back/routes/badges.ts @@ -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' @@ -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 { @@ -25,7 +26,7 @@ async function getBadges(req: Request<{ address: string }>): Promise async function airdropBadges(req: WithAuth): Promise { 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) @@ -35,5 +36,39 @@ async function airdropBadges(req: WithAuth): Promise { 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 { + 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)}` + } } diff --git a/src/clients/Governance.ts b/src/clients/Governance.ts index 2f1424204..c8ed4bf42 100644 --- a/src/clients/Governance.ts +++ b/src/clients/Governance.ts @@ -557,21 +557,25 @@ export class Governance extends API { } async airdropBadge(badgeSpecCid: string, recipients: string[]) { - const data = { - badgeSpecCid, - recipients, - } const response = await this.fetch>( `/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>( + `/badges/revoke/`, + this.options().method('POST').authorization({ sign: true }).json({ + badgeSpecCid, + recipients, + reason, + }) + ) + return response.data } } diff --git a/src/clients/OtterspaceSubgraph.ts b/src/clients/OtterspaceSubgraph.ts index 7027755aa..b5fcf9d31 100644 --- a/src/clients/OtterspaceSubgraph.ts +++ b/src/clients/OtterspaceSubgraph.ts @@ -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 @@ -72,6 +89,8 @@ export type OtterspaceBadge = { } } +type BadgeOwnership = { id: string; address: string } + export class OtterspaceSubgraph { static Cache = new Map() private readonly queryEndpoint: string @@ -148,4 +167,29 @@ export class OtterspaceSubgraph { return badges.map((badge) => badge.owner?.id.toLowerCase()).filter(Boolean) } + + async getRecipientsBadgeIds(badgeCid: string, addresses: string[]): Promise { + const badges: Pick[] = 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 || '' } + }) + } } diff --git a/src/components/Debug/BadgesAdmin.tsx b/src/components/Debug/BadgesAdmin.tsx index 35b8a3850..904c1923a 100644 --- a/src/components/Debug/BadgesAdmin.tsx +++ b/src/components/Debug/BadgesAdmin.tsx @@ -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' @@ -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([]) + const [recipients, setRecipients] = useState([]) const [badgeCid, setBadgeCid] = useState() + const [reason, setReason] = useState(OtterspaceRevokeReason.TenureEnded) const [result, setResult] = useState() - const [errorMessage, setErrorMessage] = useState() + const [errorMessage, setErrorMessage] = useState() const [formDisabled, setFormDisabled] = useState(false) async function handleAirdropBadge() { @@ -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) ) } @@ -63,28 +85,40 @@ export default function BadgesAdmin({ className }: Props) { {'Badges'}
- - -
- - setBadgeCid(value)} /> -
+
+ + +
+ + setBadgeCid(value)} /> setRecipients(addresses)} + setUsersAddresses={(addresses?: string[]) => setRecipients(addresses || [])} isDisabled={formDisabled} maxAddressesAmount={20} allowLoggedUserAccount={true} /> + + setReason(value as string)} + options={REVOKE_REASON_OPTIONS} + disabled={formDisabled} + />
{result && ( <> - {result} + {result} )}
diff --git a/src/entities/Badges/types.ts b/src/entities/Badges/types.ts index c0f884313..cf536b674 100644 --- a/src/entities/Badges/types.ts +++ b/src/entities/Badges/types.ts @@ -1,3 +1,5 @@ +import { ethers } from 'ethers' + export enum BadgeStatus { Burned = 'BURNED', Minted = 'MINTED', @@ -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(Object.values(BadgeStatus)).has(value) } @@ -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) +} diff --git a/src/entities/Badges/utils.ts b/src/entities/Badges/utils.ts index 645316586..f314fea48 100644 --- a/src/entities/Badges/utils.ts +++ b/src/entities/Badges/utils.ts @@ -1,9 +1,12 @@ import { Contract } from '@ethersproject/contracts' -import { JsonRpcProvider } from '@ethersproject/providers' import { abi as BadgesAbi } from '@otterspace-xyz/contracts/out/Badges.sol/Badges.json' +import logger from 'decentraland-gatsby/dist/entities/Development/logger' import { ethers } from 'ethers' import RpcService from '../../services/RpcService' +import { OTTERSPACE_DAO_RAFT_ID } from '../Snapshot/constants' + +import { GAS_MULTIPLIER, GasConfig } from './types' const RAFT_OWNER_PK = process.env.RAFT_OWNER_PK || '' const POLYGON_BADGES_CONTRACT_ADDRESS = process.env.POLYGON_BADGES_CONTRACT_ADDRESS || '' @@ -12,16 +15,12 @@ function checksumAddresses(addresses: string[]): string[] { return addresses.map((address) => ethers.utils.getAddress(address)) } -const GAS_MULTIPLIER = 2 -type GasConfig = { gasPrice: ethers.BigNumber; gasLimit: ethers.BigNumber } - export async function estimateGas( contract: Contract, - formattedRecipients: string[], - ipfsAddress: string, - provider: JsonRpcProvider + estimateFunction: (...args: any[]) => Promise ): Promise { - const gasLimit = await contract.estimateGas.airdrop(formattedRecipients, ipfsAddress) + const provider = RpcService.getPolygonProvider() + const gasLimit = await estimateFunction() const gasPrice = await provider.getGasPrice() const adjustedGasPrice = gasPrice.mul(GAS_MULTIPLIER) return { @@ -36,16 +35,33 @@ export async function airdrop(badgeCid: string, recipients: string[], pumpGas = const contract = new ethers.Contract(POLYGON_BADGES_CONTRACT_ADDRESS, BadgesAbi, raftOwner) const ipfsAddress = `ipfs://${badgeCid}/metadata.json` const formattedRecipients = checksumAddresses(recipients) - console.log(`Airdropping, pumping gas ${pumpGas}`) + logger.log(`Airdropping, pumping gas ${pumpGas}`) let txn if (pumpGas) { - const gasConfig = await estimateGas(contract, formattedRecipients, ipfsAddress, provider) + const gasConfig = await estimateGas(contract, async () => + contract.estimateGas.airdrop(formattedRecipients, ipfsAddress) + ) txn = await contract.connect(raftOwner).airdrop(formattedRecipients, ipfsAddress, gasConfig) } else { txn = await contract.connect(raftOwner).airdrop(formattedRecipients, ipfsAddress) } await txn.wait() - console.log('Airdropped badge with txn hash:', txn.hash) + logger.log('Airdropped badge with txn hash:', txn.hash) +} + +export async function revokeBadge(badgeId: string, reason: number) { + const provider = RpcService.getPolygonProvider() + const raftOwner = new ethers.Wallet(RAFT_OWNER_PK, provider) + const contract = new ethers.Contract(POLYGON_BADGES_CONTRACT_ADDRESS, BadgesAbi, raftOwner) + const trimmedOtterspaceId = trimOtterspaceId(OTTERSPACE_DAO_RAFT_ID) + + const gasConfig = await estimateGas(contract, async () => { + return contract.estimateGas.revokeBadge(trimmedOtterspaceId, badgeId, reason) + }) + + const txn = await contract.connect(raftOwner).revokeBadge(trimmedOtterspaceId, badgeId, reason, gasConfig) + await txn.wait() + return `Revoked badge with txn hash: ${txn.hash}` } export async function checkBalance() { @@ -56,3 +72,11 @@ export async function checkBalance() { const balanceBigNumber = ethers.BigNumber.from(balance) console.log(`Balance of ${raftOwner.address}: ${balanceInEther} ETH = ${balanceBigNumber}`) } + +export function trimOtterspaceId(rawId: string) { + const parts = rawId.split(':') + if (parts.length === 2) { + return parts[1] + } + return '' +} diff --git a/src/pages/debug.css b/src/pages/debug.css index ba0f9c8f1..dad91251c 100644 --- a/src/pages/debug.css +++ b/src/pages/debug.css @@ -22,6 +22,10 @@ margin-left: 16px !important; } +.Debug__Result { + line-break: anywhere; +} + .EnvName__Section { display: flex; height: 50px; diff --git a/src/services/BadgesService.ts b/src/services/BadgesService.ts index 6c4b87ecc..51b244e45 100644 --- a/src/services/BadgesService.ts +++ b/src/services/BadgesService.ts @@ -5,8 +5,17 @@ import AirdropJobModel, { AirdropJobStatus, AirdropOutcome } from '../back/model import { OtterspaceBadge, OtterspaceSubgraph } from '../clients/OtterspaceSubgraph' import { SnapshotGraphql } from '../clients/SnapshotGraphql' import { LEGISLATOR_BADGE_SPEC_CID } from '../constants' -import { Badge, BadgeStatus, BadgeStatusReason, UserBadges, toBadgeStatus } from '../entities/Badges/types' -import { airdrop } from '../entities/Badges/utils' +import { + Badge, + BadgeStatus, + BadgeStatusReason, + OtterspaceRevokeReason, + RevocationResult, + RevocationStatus, + UserBadges, + toBadgeStatus, +} from '../entities/Badges/types' +import { airdrop, 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' @@ -156,4 +165,50 @@ export class BadgesService { }) } } + + static async revokeBadge( + badgeCid: string, + addresses: string[], + reason = OtterspaceRevokeReason.TenureEnded + ): Promise { + const badgeOwnerships = await OtterspaceSubgraph.get().getRecipientsBadgeIds(badgeCid, addresses) + if (!badgeOwnerships || badgeOwnerships.length === 0) { + return [] + } + + const revocationResults = await Promise.all( + badgeOwnerships.map(async (badgeOwnership) => { + const trimmedId = trimOtterspaceId(badgeOwnership.id) + + if (trimmedId === '') { + return { + status: RevocationStatus.Failed, + address: badgeOwnership.address, + badgeId: badgeOwnership.id, + error: 'Invalid badge ID', + } + } + + try { + await revokeBadge(trimmedId, Number(reason)) + return { + status: RevocationStatus.Success, + address: badgeOwnership.address, + badgeId: trimmedId, + } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + } catch (error: any) { + return { + status: RevocationStatus.Failed, + address: badgeOwnership.address, + badgeId: trimmedId, + error: JSON.stringify(error?.reason || error), + } + } + }) + ) + + return revocationResults + } }