diff --git a/.gitignore b/.gitignore index 81a1521..a2942f8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules dist src/graphql/types.ts +*.sw[a-z] diff --git a/package.json b/package.json index 3451252..ba0f608 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "start": "tsdx watch", "build": "rm -rf dist && yarn tsc", "test": "tsdx test --passWithNoTests", - "lint": "tsdx lint", + "lint": "tsdx lint src --fix", "typecheck": "tsc --noEmit", "prepare": "yarn generate", "prepublishOnly": "yarn build", diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..9148206 --- /dev/null +++ b/src/client.ts @@ -0,0 +1,284 @@ +import { providers, utils } from 'ethers'; +import { contracts } from '.'; +import { QuestChainCommons } from './contracts/v1/contracts/QuestChain'; +import { + getQuestChainInfo, + GlobalInfoFragment, + isSupportedNetwork, + QuestChainInfoFragment, + NetworkId, +} from './graphql'; +import { waitUntilSubgraphIndexed } from './helpers'; +import EventEmitter from 'events'; + +export enum QuestChainRole { + OWNER = '0x0000000000000000000000000000000000000000000000000000000000000000', + ADMIN = '0xa49807205ce4d355092ef5a8a18f56e8913cf4a201fbe287825b095693c21775', + EDITOR = '0x21d1167972f621f75904fb065136bc8b53c7ba1c60ccd3a7758fbee465851e9c', + REVIEWER = '0xc10c77be35aff266144ed64c26a1fa104bae2f284ae99ac4a34203454704a185', +} + +export class QuestChainsClient extends EventEmitter { + private chainId: NetworkId; + private provider: providers.Web3Provider; + private globalInfo: Record | null = null; + + constructor(chainId: NetworkId, provider: providers.Web3Provider) { + if (!isSupportedNetwork(chainId)) { + throw new Error('Unsupported network'); + } + super(); + this.chainId = chainId; + this.provider = provider; + } + + getNetworkId(): string { + return this.chainId; + } + + async getGlobalInfo(): Promise> { + if (this.globalInfo) return this.globalInfo; + + this.globalInfo = await this.getGlobalInfo(); + + if (!this.globalInfo) { + throw new Error('Could not get global info'); + } + return this.globalInfo; + } + + async getChainInfo(chainId = this.chainId): Promise { + const globalInfo = await this.getGlobalInfo(); + if (globalInfo && globalInfo[chainId]) { + return globalInfo[chainId]; + } + throw new Error('Could not get chain info'); + } + + async getQuestChain(chainAddress: string, chainId = this.chainId): Promise { + if (!utils.isAddress(chainAddress)) { + throw new Error('Invalid quest chain address'); + } + return getQuestChainInfo(chainId, chainAddress); + } + + private async handleTx( + tx: providers.TransactionResponse, + chainId = this.chainId, + ): Promise { + this.emit('txResponse', tx); + + const receipt = await tx.wait(); + + this.emit('txReceipt', receipt); + + const indexed = await waitUntilSubgraphIndexed(chainId, receipt.blockNumber); + + this.emit('txIndexed', indexed); + return receipt; + } + + async createQuestChain( + chainInfo: QuestChainCommons.QuestChainInfoStruct, + upgrade = false, + chainId = this.chainId, + provider = this.provider, + ): Promise { + const { factoryAddress } = await this.getChainInfo(chainId); + + const factoryContract: contracts.V1.QuestChainFactory = contracts.V1.QuestChainFactory__factory.connect( + factoryAddress, + provider.getSigner(), + ); + + let tx: providers.TransactionResponse; + if (upgrade) { + tx = await factoryContract.createAndUpgrade(chainInfo, utils.randomBytes(32)); + } else { + tx = await factoryContract.create(chainInfo, utils.randomBytes(32)); + } + const receipt = await this.handleTx(tx, chainId); + + return receipt; + } + + async upgradeQuestChain( + questChain: QuestChainInfoFragment, + chainId = this.chainId, + provider = this.provider, + ): Promise { + if (questChain.premium) { + throw new Error('Quest chain is already upgraded'); + } + const { factoryAddress } = await this.getChainInfo(chainId); + + const factoryContract: contracts.V1.QuestChainFactory = contracts.V1.QuestChainFactory__factory.connect( + factoryAddress, + provider.getSigner(), + ); + + const tx: providers.TransactionResponse = await factoryContract.upgradeQuestChain(questChain.address); + + const receipt = await this.handleTx(tx, chainId); + + return receipt; + } + + async grantRole( + questChain: QuestChainInfoFragment, + userAddress: string, + roleHash: QuestChainRole, + chainId = this.chainId, + provider = this.provider, + ): Promise { + // role management is the same on all versions + const chainContract: contracts.V1.QuestChain = contracts.V1.QuestChain__factory.connect( + questChain.address, + provider.getSigner(), + ); + + const tx: providers.TransactionResponse = await chainContract.grantRole(roleHash, userAddress); + const receipt = await this.handleTx(tx, chainId); + + return receipt; + } + + async revokeRole( + questChain: QuestChainInfoFragment, + userAddress: string, + roleHash: QuestChainRole, + chainId = this.chainId, + provider = this.provider, + ): Promise { + // role management is the same on all versions + const chainContract: contracts.V1.QuestChain = contracts.V1.QuestChain__factory.connect( + questChain.address, + provider.getSigner(), + ); + + const tx: providers.TransactionResponse = await chainContract.revokeRole(roleHash, userAddress); + const receipt = await this.handleTx(tx, chainId); + + return receipt; + } + + async pauseQuestChain( + questChain: QuestChainInfoFragment, + chainId = this.chainId, + provider = this.provider, + ): Promise { + if (questChain.paused) { + throw new Error('Quest chain is already paused'); + } + // pausing is the same on all versions + const chainContract: contracts.V1.QuestChain = contracts.V1.QuestChain__factory.connect( + questChain.address, + provider.getSigner(), + ); + + const tx: providers.TransactionResponse = await chainContract.pause(); + const receipt = await this.handleTx(tx, chainId); + + return receipt; + } + + async unpauseQuestChain( + questChain: QuestChainInfoFragment, + chainId = this.chainId, + provider = this.provider, + ): Promise { + if (questChain.paused) { + throw new Error('Quest chain is already paused'); + } + // pausing is the same on all versions + const chainContract: contracts.V1.QuestChain = contracts.V1.QuestChain__factory.connect( + questChain.address, + provider.getSigner(), + ); + + const tx: providers.TransactionResponse = await chainContract.unpause(); + const receipt = await this.handleTx(tx, chainId); + + return receipt; + } + + async addQuests( + questChain: QuestChainInfoFragment, + questDetailsList: string[], + chainId = this.chainId, + provider = this.provider, + ): Promise { + if (questChain.paused) { + throw new Error('Quest chain is already paused'); + } + if (questDetailsList.length === 0) { + throw new Error('No quests provided'); + } + if (questChain.version === '0' && questDetailsList.length > 1) { + throw new Error('Adding multiple quests not supported on this quest chain version'); + } + + let tx: providers.TransactionResponse; + if (questChain.version === '0') { + const chainContract: contracts.V0.QuestChain = contracts.V0.QuestChain__factory.connect( + questChain.address, + provider.getSigner(), + ); + + tx = await chainContract.createQuest(questDetailsList[0]); + } else { + const chainContract: contracts.V1.QuestChain = contracts.V1.QuestChain__factory.connect( + questChain.address, + provider.getSigner(), + ); + + tx = await chainContract.createQuests(questDetailsList); + } + + const receipt = await this.handleTx(tx, chainId); + + return receipt; + } + + async editQuests( + questChain: QuestChainInfoFragment, + questIdList: string[], + questDetailsList: string[], + chainId = this.chainId, + provider = this.provider, + ): Promise { + if (questChain.paused) { + throw new Error('Quest chain is already paused'); + } + if (questDetailsList.length === 0) { + throw new Error('No quests provided'); + } + if (questIdList.length !== questDetailsList.length) { + throw new Error('Quest details lengths do not match'); + } + if (questChain.version === '0' && questDetailsList.length > 1) { + throw new Error('Editing multiple quests not supported on this quest chain version'); + } + + let tx: providers.TransactionResponse; + if (questChain.version === '0') { + const chainContract: contracts.V0.QuestChain = contracts.V0.QuestChain__factory.connect( + questChain.address, + provider.getSigner(), + ); + + tx = await chainContract.editQuest(questIdList[0], questDetailsList[0]); + } else { + const chainContract: contracts.V1.QuestChain = contracts.V1.QuestChain__factory.connect( + questChain.address, + provider.getSigner(), + ); + + tx = await chainContract.editQuests(questIdList, questDetailsList); + } + + const receipt = await this.handleTx(tx, chainId); + + return receipt; + } +} diff --git a/src/graphql/badgesForUser.ts b/src/graphql/badgesForUser.ts index 4537113..29ca5f0 100644 --- a/src/graphql/badgesForUser.ts +++ b/src/graphql/badgesForUser.ts @@ -8,7 +8,7 @@ export type UserBadges = { imageUrl?: string | null | undefined; questChain?: { address: string | null | undefined; - } + }; }[]; chainId: string; }; diff --git a/src/graphql/client.ts b/src/graphql/client.ts index 220ec07..726a565 100644 --- a/src/graphql/client.ts +++ b/src/graphql/client.ts @@ -1,41 +1,10 @@ import { Client, createClient, dedupExchange, fetchExchange } from 'urql'; -export type NetworkInfo = { - [chainId: string]: { - chainId: string; - subgraphName: string; - subgraphUrl: string; - }; -}; - -export const SUPPORTED_NETWORK_INFO: NetworkInfo = { - '0x89': { - chainId: '0x89', - subgraphName: 'dan13ram/quest-chains-polygon', - subgraphUrl: 'https://api.thegraph.com/subgraphs/name/dan13ram/quest-chains-polygon', - }, - '0x64': { - chainId: '0x64', - subgraphName: 'dan13ram/quest-chains-xdai', - subgraphUrl: 'https://api.thegraph.com/subgraphs/name/dan13ram/quest-chains-xdai', - }, - '0x5': { - chainId: '0x5', - subgraphName: 'dan13ram/quest-chains-goerli', - subgraphUrl: 'https://api.thegraph.com/subgraphs/name/dan13ram/quest-chains-goerli', - }, - '0x13881': { - chainId: '0x13881', - subgraphName: 'dan13ram/quest-chains-mumbai', - subgraphUrl: 'https://api.thegraph.com/subgraphs/name/dan13ram/quest-chains-mumbai', - }, -}; - -export const SUPPORTED_NETWORKS = Object.keys(SUPPORTED_NETWORK_INFO); +import { NetworkIds, NetworkInfo } from '../networks'; -const clients: Record = Object.values(SUPPORTED_NETWORK_INFO).reduce>( - (o, info) => { - o[info.chainId] = createClient({ +const clients: Record = Object.entries(NetworkInfo).reduce>( + (o, [chainId, info]) => { + o[chainId] = createClient({ url: info.subgraphUrl, exchanges: [dedupExchange, fetchExchange], }); @@ -45,7 +14,7 @@ const clients: Record = Object.values(SUPPORTED_NETWORK_INFO).re ); export const isSupportedNetwork = (chainId: string | undefined | null) => - chainId ? SUPPORTED_NETWORKS.includes(chainId) : false; + chainId ? NetworkIds.includes(chainId) : false; export const getClient = (chainId: string | undefined | null): Client => { if (!chainId || !isSupportedNetwork(chainId)) { diff --git a/src/index.ts b/src/index.ts index 383e1d8..996aea9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,5 +2,6 @@ import * as graphql from './graphql'; import * as contracts from './contracts'; import * as metadata from './metadata'; import * as helpers from './helpers'; +import { QuestChainsClient, QuestChainRole } from './client'; -export { graphql, contracts, metadata, helpers }; +export { graphql, contracts, metadata, helpers, QuestChainsClient, QuestChainRole }; diff --git a/src/networks.ts b/src/networks.ts new file mode 100644 index 0000000..c8089fb --- /dev/null +++ b/src/networks.ts @@ -0,0 +1,33 @@ +export const Networks: Record = { + POLYGON: '0x89', + GNOSIS_CHAIN: '0x64', + GOERLI: '0x5', + MUMBAI: '0x13881', +}; + +export type Values = T[keyof T]; + +export type Network = keyof typeof Networks; + +export type NetworkId = Values; + +export const NetworkInfo: Record = { + [Networks.POLYGON]: { + subgraphName: 'dan13ram/quest-chains-polygon', + subgraphUrl: 'https://api.thegraph.com/subgraphs/name/dan13ram/quest-chains-polygon', + }, + [Networks.GNOSIS_CHAIN]: { + subgraphName: 'dan13ram/quest-chains-xdai', + subgraphUrl: 'https://api.thegraph.com/subgraphs/name/dan13ram/quest-chains-xdai', + }, + [Networks.GOERLI]: { + subgraphName: 'dan13ram/quest-chains-goerli', + subgraphUrl: 'https://api.thegraph.com/subgraphs/name/dan13ram/quest-chains-goerli', + }, + [Networks.MUMBAI]: { + subgraphName: 'dan13ram/quest-chains-mumbai', + subgraphUrl: 'https://api.thegraph.com/subgraphs/name/dan13ram/quest-chains-mumbai', + }, +}; + +export const NetworkIds: Array = Object.values(Networks);