UNPKG

@cheethas/splits-sdk

Version:

Fork of the splits SDK for the 0xSplits protocol, testing experimental features

678 lines (589 loc) 19.7 kB
import { Interface } from '@ethersproject/abi' import { getAddress } from '@ethersproject/address' import { BigNumber } from '@ethersproject/bignumber' import { AddressZero, One } from '@ethersproject/constants' import { Contract, Event } from '@ethersproject/contracts' import { GraphQLClient, Variables } from 'graphql-request' import SPLIT_MAIN_ARTIFACT_ETHEREUM from './artifacts/contracts/SplitMain/ethereum/SplitMain.json' import SPLIT_MAIN_ARTIFACT_POLYGON from './artifacts/contracts/SplitMain/polygon/SplitMain.json' import { ETHEREUM_CHAIN_IDS, POLYGON_CHAIN_IDS, SPLIT_MAIN_ADDRESS, } from './constants' import { InvalidAuthError, InvalidConfigError, MissingProviderError, MissingSignerError, TransactionFailedError, UnsupportedChainIdError, UnsupportedSubgraphChainIdError, } from './errors' import { ACCOUNT_BALANCES_QUERY, formatAccountBalances, getGraphqlClient, protectedFormatSplit, RELATED_SPLITS_QUERY, SPLIT_QUERY, } from './subgraph' import type { GqlAccountBalances, GqlSplit } from './subgraph/types' import type { SplitMainType, SplitsClientConfig, CreateSplitConfig, UpdateSplitConfig, DistributeTokenConfig, WithdrawFundsConfig, InititateControlTransferConfig, CancelControlTransferConfig, AcceptControlTransferConfig, MakeSplitImmutableConfig, GetSplitBalanceConfig, UpdateSplitAndDistributeTokenConfig, SplitRecipient, Split, TokenBalances, } from './types' import { getRecipientSortedAddressesAndAllocations, getTransactionEvent, getBigNumberValue, fetchERC20TransferredTokens, addEnsNames, } from './utils' import { validateRecipients, validateDistributorFeePercent, validateAddress, } from './utils/validation' import type { SplitMain as SplitMainEthereumType } from './typechain/SplitMain/ethereum' import type { SplitMain as SplitMainPolygonType } from './typechain/SplitMain/polygon' import { Signer } from '@ethersproject/abstract-signer' const MISSING_SIGNER = '' const SPLIT_MAIN_ABI_ETHEREUM = SPLIT_MAIN_ARTIFACT_ETHEREUM.abi const splitMainInterfaceEthereum = new Interface(SPLIT_MAIN_ABI_ETHEREUM) const SPLIT_MAIN_ABI_POLYGON = SPLIT_MAIN_ARTIFACT_POLYGON.abi const splitMainInterfacePolygon = new Interface(SPLIT_MAIN_ABI_POLYGON) export class SplitsClient { private readonly _chainId: number // TODO: something better we can do here to handle typescript check for missing signer? private readonly _signer: Signer | typeof MISSING_SIGNER private readonly _splitMain: SplitMainType private readonly _graphqlClient: GraphQLClient | undefined private readonly _includeEnsNames: boolean constructor({ chainId, provider, signer, host = 'https://api.thegraph.com', includeEnsNames = false, }: SplitsClientConfig) { if (includeEnsNames && !provider) throw new InvalidConfigError( 'Must include a provider if includeEnsNames is set to true', ) if (ETHEREUM_CHAIN_IDS.includes(chainId)) this._splitMain = new Contract( SPLIT_MAIN_ADDRESS, splitMainInterfaceEthereum, provider, ) as SplitMainEthereumType else if (POLYGON_CHAIN_IDS.includes(chainId)) this._splitMain = new Contract( SPLIT_MAIN_ADDRESS, splitMainInterfacePolygon, provider, ) as SplitMainPolygonType else throw new UnsupportedChainIdError(chainId) this._chainId = chainId this._signer = signer ?? MISSING_SIGNER this._graphqlClient = getGraphqlClient(chainId, host) this._includeEnsNames = includeEnsNames } // Write actions async createSplit({ recipients, distributorFeePercent, controller = AddressZero, }: CreateSplitConfig): Promise<{ splitId: string event: Event }> { validateRecipients(recipients) validateDistributorFeePercent(distributorFeePercent) this._requireSigner() const [accounts, percentAllocations] = getRecipientSortedAddressesAndAllocations(recipients) const distributorFee = getBigNumberValue(distributorFeePercent) const createSplitTx = await this._splitMain .connect(this._signer) .createSplit(accounts, percentAllocations, distributorFee, controller) const event = await getTransactionEvent( createSplitTx, this._splitMain.interface.getEvent('CreateSplit').format(), ) if (event && event.args) return { splitId: event.args.split, event, } throw new TransactionFailedError() } async updateSplit({ splitId, recipients, distributorFeePercent, }: UpdateSplitConfig): Promise<{ event: Event }> { validateAddress(splitId) validateRecipients(recipients) validateDistributorFeePercent(distributorFeePercent) this._requireSigner() await this._requireController(splitId) const [accounts, percentAllocations] = getRecipientSortedAddressesAndAllocations(recipients) const distributorFee = getBigNumberValue(distributorFeePercent) const updateSplitTx = await this._splitMain .connect(this._signer) .updateSplit(splitId, accounts, percentAllocations, distributorFee) const event = await getTransactionEvent( updateSplitTx, this._splitMain.interface.getEvent('UpdateSplit').format(), ) if (event) return { event } throw new TransactionFailedError() } async distributeToken({ splitId, token, distributorAddress, }: DistributeTokenConfig): Promise<{ event: Event }> { validateAddress(splitId) validateAddress(token) this._requireSigner() // TODO: how to remove this, needed for typescript check right now if (!this._signer) throw new Error() const distributorPayoutAddress = distributorAddress ? distributorAddress : await this._signer.getAddress() validateAddress(distributorPayoutAddress) // TO DO: handle bad split id/no metadata found const { recipients, distributorFeePercent } = await this.getSplitMetadata({ splitId, }) const [accounts, percentAllocations] = getRecipientSortedAddressesAndAllocations(recipients) const distributorFee = getBigNumberValue(distributorFeePercent) const distributeTokenTx = await (token === AddressZero ? this._splitMain .connect(this._signer) .distributeETH( splitId, accounts, percentAllocations, distributorFee, distributorPayoutAddress, ) : this._splitMain .connect(this._signer) .distributeERC20( splitId, token, accounts, percentAllocations, distributorFee, distributorPayoutAddress, )) const eventSignature = token === AddressZero ? this._splitMain.interface.getEvent('DistributeETH').format() : this._splitMain.interface.getEvent('DistributeERC20').format() const event = await getTransactionEvent(distributeTokenTx, eventSignature) if (event) return { event } throw new TransactionFailedError() } async updateSplitAndDistributeToken({ splitId, token, recipients, distributorFeePercent, distributorAddress, }: UpdateSplitAndDistributeTokenConfig): Promise<{ event: Event }> { validateAddress(splitId) validateAddress(token) validateRecipients(recipients) validateDistributorFeePercent(distributorFeePercent) this._requireSigner() await this._requireController(splitId) // TODO: how to remove this, needed for typescript check right now if (!this._signer) throw new Error() const [accounts, percentAllocations] = getRecipientSortedAddressesAndAllocations(recipients) const distributorFee = getBigNumberValue(distributorFeePercent) const distributorPayoutAddress = distributorAddress ? distributorAddress : await this._signer.getAddress() validateAddress(distributorPayoutAddress) const updateAndDistributeTx = await (token === AddressZero ? this._splitMain .connect(this._signer) .updateAndDistributeETH( splitId, accounts, percentAllocations, distributorFee, distributorPayoutAddress, ) : this._splitMain .connect(this._signer) .updateAndDistributeERC20( splitId, token, accounts, percentAllocations, distributorFee, distributorPayoutAddress, )) const eventSignature = token === AddressZero ? this._splitMain.interface.getEvent('DistributeETH').format() : this._splitMain.interface.getEvent('DistributeERC20').format() const event = await getTransactionEvent( updateAndDistributeTx, eventSignature, ) if (event) return { event } throw new TransactionFailedError() } async withdrawFunds({ address, tokens }: WithdrawFundsConfig): Promise<{ event: Event }> { validateAddress(address) this._requireSigner() const withdrawEth = tokens.includes(AddressZero) ? 1 : 0 const erc20s = tokens.filter((token) => token !== AddressZero) const withdrawTx = await this._splitMain .connect(this._signer) .withdraw(address, withdrawEth, erc20s) const event = await getTransactionEvent( withdrawTx, this._splitMain.interface.getEvent('Withdrawal').format(), ) if (event) return { event } throw new TransactionFailedError() } async initiateControlTransfer({ splitId, newController, }: InititateControlTransferConfig): Promise<{ event: Event }> { validateAddress(splitId) this._requireSigner() await this._requireController(splitId) const transferSplitTx = await this._splitMain .connect(this._signer) .transferControl(splitId, newController) const event = await getTransactionEvent( transferSplitTx, this._splitMain.interface.getEvent('InitiateControlTransfer').format(), ) if (event) return { event } throw new TransactionFailedError() } async cancelControlTransfer({ splitId, }: CancelControlTransferConfig): Promise<{ event: Event }> { validateAddress(splitId) this._requireSigner() await this._requireController(splitId) const cancelTransferSplitTx = await this._splitMain .connect(this._signer) .cancelControlTransfer(splitId) const event = await getTransactionEvent( cancelTransferSplitTx, this._splitMain.interface.getEvent('CancelControlTransfer').format(), ) if (event) return { event } throw new TransactionFailedError() } async acceptControlTransfer({ splitId, }: AcceptControlTransferConfig): Promise<{ event: Event }> { validateAddress(splitId) this._requireSigner() await this._requireNewPotentialController(splitId) const acceptTransferSplitTx = await this._splitMain .connect(this._signer) .acceptControl(splitId) const event = await getTransactionEvent( acceptTransferSplitTx, this._splitMain.interface.getEvent('ControlTransfer').format(), ) if (event) return { event } throw new TransactionFailedError() } async makeSplitImmutable({ splitId }: MakeSplitImmutableConfig): Promise<{ event: Event }> { validateAddress(splitId) this._requireSigner() await this._requireController(splitId) const makeSplitImmutableTx = await this._splitMain .connect(this._signer) .makeSplitImmutable(splitId) const event = await getTransactionEvent( makeSplitImmutableTx, this._splitMain.interface.getEvent('ControlTransfer').format(), ) if (event) return { event } throw new TransactionFailedError() } // Read actions async getSplitBalance({ splitId, token = AddressZero, }: GetSplitBalanceConfig): Promise<{ balance: BigNumber }> { validateAddress(splitId) this._requireSplitMain() const balance = token === AddressZero ? await this._splitMain.getETHBalance(splitId) : await this._splitMain.getERC20Balance(splitId, token) return { balance } } async predictImmutableSplitAddress({ recipients, distributorFeePercent, }: { recipients: SplitRecipient[] distributorFeePercent: number }): Promise<{ splitId: string }> { validateRecipients(recipients) validateDistributorFeePercent(distributorFeePercent) this._requireSplitMain() const [accounts, percentAllocations] = getRecipientSortedAddressesAndAllocations(recipients) const distributorFee = getBigNumberValue(distributorFeePercent) const splitId = await this._splitMain.predictImmutableSplitAddress( accounts, percentAllocations, distributorFee, ) return { splitId } } async getController({ splitId }: { splitId: string }): Promise<{ controller: string }> { validateAddress(splitId) this._requireSplitMain() const controller = await this._splitMain.getController(splitId) return { controller } } async getNewPotentialController({ splitId }: { splitId: string }): Promise<{ newPotentialController: string }> { validateAddress(splitId) this._requireSplitMain() const newPotentialController = await this._splitMain.getNewPotentialController(splitId) return { newPotentialController } } async getHash({ splitId }: { splitId: string }): Promise<{ hash: string }> { validateAddress(splitId) this._requireSplitMain() const hash = await this._splitMain.getHash(splitId) return { hash } } // Graphql read actions async getSplitMetadata({ splitId }: { splitId: string }): Promise<Split> { validateAddress(splitId) const response = await this._makeGqlRequest<{ split: GqlSplit }>( SPLIT_QUERY, { splitId: splitId.toLowerCase(), }, ) return await this._formatSplit(response.split) } async getRelatedSplits({ address }: { address: string }): Promise<{ receivingFrom: Split[] controlling: Split[] pendingControl: Split[] }> { validateAddress(address) const response = await this._makeGqlRequest<{ receivingFrom: { split: GqlSplit }[] controlling: GqlSplit[] pendingControl: GqlSplit[] }>(RELATED_SPLITS_QUERY, { accountId: address.toLowerCase() }) const [receivingFrom, controlling, pendingControl] = await Promise.all([ Promise.all( response.receivingFrom.map( async (recipient) => await this._formatSplit(recipient.split), ), ), Promise.all( response.controlling.map( async (gqlSplit) => await this._formatSplit(gqlSplit), ), ), Promise.all( response.pendingControl.map( async (gqlSplit) => await this._formatSplit(gqlSplit), ), ), ]) return { receivingFrom, controlling, pendingControl, } } async getSplitEarnings({ splitId, includeActiveBalances = true, }: { splitId: string includeActiveBalances?: boolean }): Promise<{ activeBalances?: TokenBalances distributed: TokenBalances }> { validateAddress(splitId) if (includeActiveBalances && !this._splitMain.provider) throw new MissingProviderError( 'Provider required to get split active balances. Please update your call to the SplitsClient constructor with a valid provider, or set includeActiveBalances to false', ) const response = await this._makeGqlRequest<{ accountBalances: GqlAccountBalances }>(ACCOUNT_BALANCES_QUERY, { accountId: splitId.toLowerCase(), }) const distributed = formatAccountBalances( response.accountBalances.withdrawals, ) if (!includeActiveBalances) return { distributed, } const internalBalances = formatAccountBalances( response.accountBalances.internalBalances, ) const internalTokens = Object.keys(internalBalances) // TODO: how to get rid of this if statement? typescript is complaining without it const erc20Tokens = this._splitMain.provider ? await fetchERC20TransferredTokens( this._chainId, this._splitMain.provider, splitId, ) : [] const tokens = Array.from( new Set(internalTokens.concat(erc20Tokens).concat([AddressZero])), ) const activeBalances = ( await Promise.all( tokens.map((token) => this.getSplitBalance({ splitId, token })), ) ).reduce((acc, balanceDict, index) => { const balance = balanceDict.balance const token = getAddress(tokens[index]) if (balance > One) acc[token] = balance return acc }, {} as TokenBalances) return { activeBalances, distributed, } } async getUserEarnings({ userId }: { userId: string }): Promise<{ withdrawn: TokenBalances activeBalances: TokenBalances }> { validateAddress(userId) const response = await this._makeGqlRequest<{ accountBalances: GqlAccountBalances }>(ACCOUNT_BALANCES_QUERY, { accountId: userId.toLowerCase(), }) const withdrawn = formatAccountBalances( response.accountBalances.withdrawals, ) const activeBalances = formatAccountBalances( response.accountBalances.internalBalances, ) return { withdrawn, activeBalances } } // Helper functions private _requireSplitMain() { if (!this._splitMain.provider) throw new MissingProviderError( 'Provider required to perform this action, please update your call to the SplitsClient constructor', ) } private _requireSigner() { this._requireSplitMain() if (!this._signer) throw new MissingSignerError( 'Signer required to perform this action, please update your call to the SplitsClient constructor', ) } private async _requireController(splitId: string) { const { controller } = await this.getController({ splitId }) // TODO: how to get rid of this, needed for typescript check if (!this._signer) throw new Error() const signerAddress = await this._signer.getAddress() if (controller !== signerAddress) throw new InvalidAuthError( `Action only available to the split controller. Split id: ${splitId}, split controller: ${controller}, signer: ${signerAddress}`, ) } private async _requireNewPotentialController(splitId: string) { const { newPotentialController } = await this.getNewPotentialController({ splitId, }) // TODO: how to get rid of this, needed for typescript check if (!this._signer) throw new Error() const signerAddress = await this._signer.getAddress() if (newPotentialController !== signerAddress) throw new InvalidAuthError( `Action only available to the split's new potential controller. Split new potential controller: ${newPotentialController}. Signer: ${signerAddress}`, ) } private async _makeGqlRequest<ResponseType>( query: string, variables?: Variables, ): Promise<ResponseType> { if (!this._graphqlClient) { throw new UnsupportedSubgraphChainIdError() } // TODO: any error handling? need to add try/catch if so const result = await this._graphqlClient.request(query, variables) return result } private async _formatSplit(gqlSplit: GqlSplit): Promise<Split> { const split = protectedFormatSplit(gqlSplit) if (this._includeEnsNames) { await addEnsNames(this._splitMain.provider, split.recipients) } return split } }