UNPKG

@0xsplits/splits-sdk

Version:

SDK for the 0xSplits protocol

949 lines (841 loc) 27.6 kB
import { Client } from '@urql/core' import { DocumentNode } from 'graphql' import { Address, getAddress, zeroAddress } from 'viem' import { SPLITS_SUBGRAPH_CHAIN_IDS } from '../constants' import { DataClientConfig, FormattedContractEarnings, FormattedEarningsByContract, FormattedSplitEarnings, FormattedTokenBalances, FormattedUserEarningsByContract, LiquidSplit, Split, SplitsContract, SplitsPublicClient, Swapper, VestingModule, WaterfallModule, } from '../types' import { AccountNotFoundError, InvalidArgumentError, InvalidConfigError, MissingPublicClientError, UnsupportedSubgraphChainIdError, } from '../errors' import { ACCOUNT_QUERY, FULL_ACCOUNT_QUERY, GqlVariables, formatFullGqlAccount, formatGqlAccount, getGraphqlClient, } from '../subgraph' import { GqlAccount, GqlLiquidSplit, GqlPassThroughWallet, GqlSplit, GqlSwapper, GqlVestingModule, GqlWaterfallModule, IAccountType, ILiquidSplit, ISplit, ISubgraphAccount, ISwapper, IVestingModule, IWaterfallModule, } from '../subgraph/types' import { MAX_RELATED_ACCOUNTS } from '../subgraph/constants' import { formatGqlSplit, protectedFormatSplit } from '../subgraph/split' import { formatGqlWaterfallModule, protectedFormatWaterfallModule, } from '../subgraph/waterfall' import { formatGqlVestingModule, protectedFormatVestingModule, } from '../subgraph/vesting' import { formatGqlSwapper, protectedFormatSwapper } from '../subgraph/swapper' import { formatGqlPassThroughWallet } from '../subgraph/pass-through-wallet' import { formatGqlLiquidSplit, protectedFormatLiquidSplit, } from '../subgraph/liquid' import { formatAccountBalances, formatContractEarnings } from '../subgraph/user' import { addEnsNames, fetchActiveBalances, fetchContractBalancesWithAlchemy, fetchERC20TransferredTokens, fromBigIntToTokenValue, getTokenData, isAlchemyPublicClient, isLogsPublicClient, mergeFormattedTokenBalances, validateAddress, } from '../utils' export class DataClient { readonly _ensPublicClient: SplitsPublicClient | undefined // DEPRECATED readonly _publicClient: SplitsPublicClient | undefined // DEPRECATED readonly _publicClients: | { [chainId: number]: SplitsPublicClient } | undefined private readonly _graphqlClient: Client | undefined readonly _includeEnsNames: boolean constructor({ publicClient, publicClients, ensPublicClient, apiConfig, includeEnsNames = false, }: DataClientConfig) { if ( includeEnsNames && !publicClient && !publicClients?.[1] && !ensPublicClient ) throw new InvalidConfigError( 'Must include a mainnet public client if includeEnsNames is set to true', ) this._ensPublicClient = publicClients?.[1] ?? ensPublicClient ?? publicClient this._publicClient = publicClient this._publicClients = publicClients this._includeEnsNames = includeEnsNames this._graphqlClient = getGraphqlClient(apiConfig) } protected _requirePublicClient(chainId: number) { this._getPublicClient(chainId) } protected _getPublicClient(chainId: number): SplitsPublicClient { if (this._publicClients && this._publicClients[chainId]) { return this._publicClients[chainId] } if (!this._publicClient) throw new MissingPublicClientError( `Public client required on chain ${chainId} to perform this action, please update your call to the constructor`, ) return this._publicClient } protected async _makeGqlRequest<ResponseType>( query: DocumentNode, variables?: GqlVariables, ): Promise<ResponseType> { if (!this._graphqlClient) { throw new UnsupportedSubgraphChainIdError() } if (variables?.chainId && typeof variables.chainId === 'string') { if (!SPLITS_SUBGRAPH_CHAIN_IDS.includes(Number(variables.chainId))) { throw new UnsupportedSubgraphChainIdError() } } const response = await this._graphqlClient .query(query, variables) .toPromise() if (response.error) { throw response.error } return response.data } protected async _loadAccount( accountId: string, chainId: number, ): Promise<IAccountType | undefined> { const result = await this._makeGqlRequest<{ account: GqlAccount }>(ACCOUNT_QUERY, { accountId: accountId.toLowerCase(), chainId: chainId.toString(), }) if (!result.account) return return formatGqlAccount(result.account) } protected async _loadFullAccount( accountId: string, chainId: number, ): Promise<ISubgraphAccount> { const result = await this._makeGqlRequest<{ account: GqlAccount relatedAccounts: { controllingSplits: GqlSplit[] pendingControlSplits: GqlSplit[] ownedSwappers: GqlSwapper[] ownedPassThroughWallets: GqlPassThroughWallet[] upstreamSplits: GqlSplit[] upstreamLiquidSplits: GqlLiquidSplit[] upstreamWaterfalls: GqlWaterfallModule[] upstreamVesting: GqlVestingModule[] upstreamSwappers: GqlSwapper[] upstreamPassThroughWallets: GqlPassThroughWallet[] } }>(FULL_ACCOUNT_QUERY, { accountId: accountId.toLowerCase(), chainId: chainId.toString(), relatedAccountsLimit: MAX_RELATED_ACCOUNTS, }) const response: ISubgraphAccount = {} const relatedAccounts = result.relatedAccounts if (result.account) { response.upstreamSplits = relatedAccounts.upstreamSplits?.map((gqlSplit) => formatGqlSplit(gqlSplit), ) ?? [] response.upstreamWaterfalls = relatedAccounts.upstreamWaterfalls?.map((waterfallModule) => formatGqlWaterfallModule(waterfallModule), ) ?? [] response.upstreamVesting = relatedAccounts.upstreamVesting?.map((vestingModule) => formatGqlVestingModule(vestingModule), ) ?? [] response.upstreamSwappers = relatedAccounts.upstreamSwappers?.map((swapper) => formatGqlSwapper(swapper), ) ?? [] response.upstreamPassThroughWallets = relatedAccounts.upstreamPassThroughWallets?.map((passThroughWallet) => formatGqlPassThroughWallet(passThroughWallet), ) ?? [] response.upstreamLiquidSplits = relatedAccounts.upstreamLiquidSplits?.map((gqlLiquidSplit) => formatGqlLiquidSplit(gqlLiquidSplit), ) ?? [] if (response.upstreamLiquidSplits.length > 0) { response.upstreamSplits = response.upstreamSplits.concat( relatedAccounts.upstreamLiquidSplits.map((gqlLiquidSplit) => formatGqlSplit(gqlLiquidSplit.split), ), ) } response.controllingSplits = relatedAccounts.controllingSplits?.map((split) => formatGqlSplit(split), ) ?? [] response.pendingControlSplits = relatedAccounts.pendingControlSplits?.map((split) => formatGqlSplit(split), ) ?? [] response.ownedSwappers = relatedAccounts.ownedSwappers?.map((swapper) => formatGqlSwapper(swapper), ) ?? [] response.ownedPassThroughWallets = relatedAccounts.ownedPassThroughWallets?.map((passThroughWallet) => formatGqlPassThroughWallet(passThroughWallet), ) ?? [] response.account = formatFullGqlAccount( result.account, response.upstreamSplits, response.upstreamVesting, response.upstreamWaterfalls, response.upstreamLiquidSplits, response.upstreamSwappers, response.upstreamPassThroughWallets, relatedAccounts.controllingSplits, relatedAccounts.pendingControlSplits, relatedAccounts.ownedSwappers, relatedAccounts.ownedPassThroughWallets, ) const allPassThroughWallets = [...relatedAccounts.ownedPassThroughWallets] if (result.account.__typename === 'PassThroughWallet') { allPassThroughWallets.push(result.account) } if (result.account.__typename === 'LiquidSplit') { // Not really an upstream split, but it's just used for loading. Should probably update that name. Maybe related splits? response.upstreamSplits.push(formatGqlSplit(result.account.split)) } else if (result.account.__typename === 'WaterfallModule') { result.account.tranches.map((gqlWaterfallTranche) => { if (gqlWaterfallTranche.recipient.__typename === 'Split') { response.upstreamSplits = response.upstreamSplits ?? [] response.upstreamSplits.push( formatGqlSplit(gqlWaterfallTranche.recipient), ) } }) } if (allPassThroughWallets.length > 0) { allPassThroughWallets.map((gqlPassThroughWallet) => { if (gqlPassThroughWallet.passThroughAccount.__typename === 'Split') { response.upstreamSplits = response.upstreamSplits ?? [] response.upstreamSplits.push( formatGqlSplit(gqlPassThroughWallet.passThroughAccount), ) gqlPassThroughWallet.passThroughAccount.recipients.map( (gqlRecipient) => { if (gqlRecipient.account.__typename === 'Swapper') { response.upstreamSwappers = response.upstreamSwappers ?? [] response.upstreamSwappers.push( formatGqlSwapper(gqlRecipient.account), ) } }, ) } }) } } return response } protected async _getUserBalancesByContract({ chainId, userAddress, contractAddresses, }: { chainId: number userAddress: string contractAddresses?: string[] }): Promise<{ contractEarnings: FormattedEarningsByContract }> { const response = await this._loadAccount(userAddress, chainId) if (!response) throw new AccountNotFoundError('user', userAddress, chainId) const contractEarnings = formatContractEarnings( response.contractEarnings, contractAddresses, ) return { contractEarnings, } } protected async _getAccountBalances({ chainId, accountAddress, includeActiveBalances, erc20TokenList, }: { chainId: number accountAddress: Address includeActiveBalances: boolean erc20TokenList?: string[] }): Promise<{ withdrawn: FormattedTokenBalances distributed: FormattedTokenBalances activeBalances?: FormattedTokenBalances }> { const functionPublicClient = this._getPublicClient(chainId) const response = await this._loadAccount(accountAddress, chainId) if (!response) throw new AccountNotFoundError('account', accountAddress, chainId) const withdrawn = response.type === 'user' ? formatAccountBalances(response.withdrawn) : {} const distributed = formatAccountBalances(response.distributions) if (!includeActiveBalances) { return { withdrawn, distributed, } } const splitmainBalances = formatAccountBalances(response.splitmainBalances) const warehouseBalances = formatAccountBalances(response.warehouseBalances) if (response.type === 'user') { return { withdrawn, distributed, activeBalances: mergeFormattedTokenBalances([ splitmainBalances, warehouseBalances, ]), } } // Need to fetch current balance. Handle alchemy/infura with logs, and all other rpc's with token list if (!functionPublicClient) throw new MissingPublicClientError( 'Public client required to get active balances. Please update your call to the client constructor, or set includeActiveBalances to false', ) if (functionPublicClient.chain?.id !== chainId) { throw new InvalidArgumentError( `Public client is set to chain id ${functionPublicClient.chain?.id}, but active balances are being fetched on chain ${chainId}. Active balances can only be fetched on the same chain as the public client.`, ) } const tokenList = erc20TokenList ?? [] let balances: FormattedTokenBalances if ( erc20TokenList === undefined && isAlchemyPublicClient(functionPublicClient) ) { // If no token list passed in and we're using alchemy, fetch all balances with alchemy's custom api balances = await fetchContractBalancesWithAlchemy( chainId, accountAddress, functionPublicClient, ) } else { if (erc20TokenList === undefined) { // If no token list passed in, make sure the public client supports logs and then fetch all erc20 tokens if (!isLogsPublicClient(functionPublicClient)) throw new InvalidArgumentError( 'Token list required if public client is not alchemy or infura', ) const transferredErc20Tokens = await fetchERC20TransferredTokens( chainId, functionPublicClient, accountAddress, ) tokenList.push(...transferredErc20Tokens) } // Include already distributed tokens in list for balances const customTokens = Object.keys(distributed) ?? [] const fullTokenList = Array.from( new Set( [zeroAddress, ...tokenList] .concat(Object.keys(splitmainBalances)) .concat(Object.keys(warehouseBalances)) .concat(customTokens) .map((token) => getAddress(token)), ), ) balances = await fetchActiveBalances( chainId, accountAddress, functionPublicClient, fullTokenList, ) } const allTokens = Array.from( new Set( Object.keys(balances) .concat(Object.keys(splitmainBalances)) .concat(Object.keys(warehouseBalances)), ), ) const filteredBalances = allTokens.reduce((acc, token) => { const splitmainBalanceAmount = splitmainBalances[token]?.rawAmount ?? BigInt(0) const warehouseBalanceAmount = warehouseBalances[token]?.rawAmount ?? BigInt(0) const contractBalanceAmount = balances[token]?.rawAmount ?? BigInt(0) // SplitMain leaves a balance of 1 for gas efficiency in internal balances. // Splits leave a balance of 1 (for erc20) for gas efficiency const tokenBalance = (splitmainBalanceAmount > BigInt(1) ? splitmainBalanceAmount : BigInt(0)) + (warehouseBalanceAmount > BigInt(1) ? warehouseBalanceAmount : BigInt(0)) + (contractBalanceAmount > BigInt(1) ? contractBalanceAmount : BigInt(0)) const symbol = splitmainBalances[token]?.symbol ?? warehouseBalances[token]?.symbol ?? balances[token]?.symbol const decimals = splitmainBalances[token]?.decimals ?? warehouseBalances[token]?.decimals ?? balances[token]?.decimals const formattedAmount = fromBigIntToTokenValue(tokenBalance, decimals) if (tokenBalance > BigInt(0)) acc[token] = { rawAmount: tokenBalance, formattedAmount, symbol, decimals, } return acc }, {} as FormattedTokenBalances) return { withdrawn, distributed, activeBalances: filteredBalances, } } async getContractEarnings({ chainId, contractAddress, includeActiveBalances = true, erc20TokenList, }: { chainId: number contractAddress: string includeActiveBalances?: boolean erc20TokenList?: string[] }): Promise<FormattedContractEarnings> { validateAddress(contractAddress) if (includeActiveBalances) this._requirePublicClient(chainId) const { distributed, activeBalances } = await this._getAccountBalances({ chainId, accountAddress: getAddress(contractAddress), includeActiveBalances, erc20TokenList, }) if (!includeActiveBalances) return { distributed } return { distributed, activeBalances } } async getSplitMetadata({ chainId, splitAddress, }: { chainId: number splitAddress: string }): Promise<Split> { validateAddress(splitAddress) const response = await this._loadAccount(splitAddress, chainId) if ( !response || (response.type !== 'split' && response.type !== 'splitV2' && response.type !== 'splitV2o1') ) throw new AccountNotFoundError('split', splitAddress, chainId) return await this.formatSplit(response) } async getAccountMetadata({ chainId, accountAddress, }: { chainId: number accountAddress: string }): Promise<SplitsContract | undefined> { validateAddress(accountAddress) this._requirePublicClient(chainId) const response = await this._loadAccount(accountAddress, chainId) if (!response) throw new AccountNotFoundError('account', accountAddress, chainId) return await this._formatAccount(chainId, response) } // Graphql read actions async getRelatedSplits({ chainId, address, }: { chainId: number address: string }): Promise<{ receivingFrom: Split[] controlling: Split[] pendingControl: Split[] }> { validateAddress(address) const response = await this._loadFullAccount(address, chainId) const [receivingFrom, controlling, pendingControl] = await Promise.all([ Promise.all( response.upstreamSplits ? response.upstreamSplits.map(async (recipient) => this.formatSplit(recipient), ) : [], ), Promise.all( response.controllingSplits ? response.controllingSplits.map(async (recipient) => this.formatSplit(recipient), ) : [], ), Promise.all( response.pendingControlSplits ? response.pendingControlSplits.map(async (recipient) => this.formatSplit(recipient), ) : [], ), ]) return { receivingFrom, controlling, pendingControl, } } async getSplitEarnings({ chainId, splitAddress, includeActiveBalances = true, erc20TokenList, }: { chainId: number splitAddress: string includeActiveBalances?: boolean erc20TokenList?: string[] }): Promise<FormattedSplitEarnings> { validateAddress(splitAddress) if (includeActiveBalances) this._requirePublicClient(chainId) const { distributed, activeBalances } = await this._getAccountBalances({ chainId, accountAddress: getAddress(splitAddress), includeActiveBalances, erc20TokenList, }) if (!includeActiveBalances) return { distributed } return { distributed, activeBalances } } async getUserEarnings({ chainId, userAddress, }: { chainId: number userAddress: string }): Promise<{ withdrawn: FormattedTokenBalances activeBalances: FormattedTokenBalances }> { validateAddress(userAddress) const { withdrawn, activeBalances } = await this._getAccountBalances({ chainId, accountAddress: getAddress(userAddress), includeActiveBalances: true, }) if (!activeBalances) throw new Error('Missing active balances') return { withdrawn, activeBalances } } async getUserEarningsByContract({ chainId, userAddress, contractAddresses, }: { chainId: number userAddress: string contractAddresses?: string[] }): Promise<FormattedUserEarningsByContract> { validateAddress(userAddress) if (contractAddresses) { contractAddresses.map((contractAddress) => validateAddress(contractAddress), ) } const { contractEarnings } = await this._getUserBalancesByContract({ chainId, userAddress, contractAddresses, }) const [withdrawn, activeBalances] = Object.values(contractEarnings).reduce( ( acc, { withdrawn: contractWithdrawn, activeBalances: contractActiveBalances, }, ) => { Object.keys(contractWithdrawn).map((tokenId) => { if (!acc[0][tokenId]) acc[0][tokenId] = { symbol: contractWithdrawn[tokenId].symbol, decimals: contractWithdrawn[tokenId].decimals, rawAmount: BigInt(0), formattedAmount: '0', } const rawAmount = acc[0][tokenId].rawAmount + contractWithdrawn[tokenId].rawAmount const formattedAmount = fromBigIntToTokenValue( rawAmount, contractWithdrawn[tokenId].decimals, ) acc[0][tokenId].rawAmount = rawAmount acc[0][tokenId].formattedAmount = formattedAmount }) Object.keys(contractActiveBalances).map((tokenId) => { if (!acc[1][tokenId]) acc[1][tokenId] = { symbol: contractActiveBalances[tokenId].symbol, decimals: contractActiveBalances[tokenId].decimals, rawAmount: BigInt(0), formattedAmount: '0', } const rawAmount = acc[1][tokenId].rawAmount + contractActiveBalances[tokenId].rawAmount const formattedAmount = fromBigIntToTokenValue( rawAmount, contractActiveBalances[tokenId].decimals, ) acc[1][tokenId].rawAmount = rawAmount acc[1][tokenId].formattedAmount = formattedAmount }) return acc }, [{} as FormattedTokenBalances, {} as FormattedTokenBalances], ) return { withdrawn, activeBalances, earningsByContract: contractEarnings, } } async getLiquidSplitMetadata({ chainId, liquidSplitAddress, }: { chainId: number liquidSplitAddress: string }): Promise<LiquidSplit> { validateAddress(liquidSplitAddress) const response = await this._loadAccount(liquidSplitAddress, chainId) if (!response || response.type !== 'liquidSplit') throw new AccountNotFoundError( 'liquid split', liquidSplitAddress, chainId, ) return await this.formatLiquidSplit(chainId, response) } async getSwapperMetadata({ chainId, swapperAddress, }: { chainId: number swapperAddress: string }): Promise<Swapper> { validateAddress(swapperAddress) const response = await this._loadAccount(swapperAddress, chainId) if (!response || response.type !== 'swapper') throw new AccountNotFoundError('swapper', swapperAddress, chainId) return await this.formatSwapper(response) } async getVestingMetadata({ chainId, vestingModuleAddress, }: { chainId: number vestingModuleAddress: string }): Promise<VestingModule> { validateAddress(vestingModuleAddress) const response = await this._loadAccount(vestingModuleAddress, chainId) if (!response || response.type !== 'vesting') throw new AccountNotFoundError( 'vesting module', vestingModuleAddress, chainId, ) return await this.formatVestingModule(chainId, response) } async getWaterfallMetadata({ chainId, waterfallModuleAddress, }: { chainId: number waterfallModuleAddress: string }): Promise<WaterfallModule> { validateAddress(waterfallModuleAddress) const response = await this._loadAccount(waterfallModuleAddress, chainId) if (!response || response.type != 'waterfall') throw new AccountNotFoundError( 'waterfall module', waterfallModuleAddress, chainId, ) return await this.formatWaterfallModule(chainId, response) } // Helper functions private async _formatAccount( chainId: number, gqlAccount: IAccountType, ): Promise<SplitsContract | undefined> { if (!gqlAccount) return if (gqlAccount.type === 'split') return await this.formatSplit(gqlAccount) else if (gqlAccount.type === 'waterfall') return await this.formatWaterfallModule(chainId, gqlAccount) else if (gqlAccount.type === 'liquidSplit') return await this.formatLiquidSplit(chainId, gqlAccount) else if (gqlAccount.type === 'swapper') return await this.formatSwapper(gqlAccount) } private async formatWaterfallModule( chainId: number, gqlWaterfallModule: IWaterfallModule, ): Promise<WaterfallModule> { this._requirePublicClient(chainId) const publicClient = this._getPublicClient(chainId) const tokenData = await getTokenData( chainId, getAddress(gqlWaterfallModule.token), publicClient, ) const waterfallModule = protectedFormatWaterfallModule( gqlWaterfallModule, tokenData.symbol, tokenData.decimals, ) if (this._includeEnsNames) { const ensRecipients = waterfallModule.tranches .map((tranche) => { return tranche.recipient }) .concat( waterfallModule.nonWaterfallRecipient ? [waterfallModule.nonWaterfallRecipient] : [], ) await addEnsNames(this._ensPublicClient ?? publicClient, ensRecipients) } return waterfallModule } private async formatVestingModule( chainId: number, gqlVestingModule: IVestingModule, ): Promise<VestingModule> { this._requirePublicClient(chainId) const publicClient = this._getPublicClient(chainId) const tokenIds = Array.from( new Set(gqlVestingModule.streams?.map((stream) => stream.token) ?? []), ) const tokenData: { [token: string]: { symbol: string; decimals: number } } = {} await Promise.all( tokenIds.map(async (token) => { const result = await getTokenData( chainId, getAddress(token), publicClient, ) tokenData[token] = result }), ) const vestingModule = protectedFormatVestingModule( gqlVestingModule, tokenData, ) if (this._includeEnsNames) { await addEnsNames(this._ensPublicClient ?? publicClient, [ vestingModule.beneficiary, ]) } return vestingModule } private async formatSwapper(gqlSwapper: ISwapper): Promise<Swapper> { const swapper = protectedFormatSwapper(gqlSwapper) if (this._includeEnsNames) { if (!this._ensPublicClient) throw new Error() const ensRecipients = [swapper.beneficiary].concat( swapper.owner ? [swapper.owner] : [], ) await addEnsNames(this._ensPublicClient, ensRecipients) } return swapper } private async formatLiquidSplit( chainId: number, gqlLiquidSplit: ILiquidSplit, ): Promise<LiquidSplit> { this._requirePublicClient(chainId) const liquidSplit = protectedFormatLiquidSplit(gqlLiquidSplit) if (this._includeEnsNames) { await addEnsNames( this._ensPublicClient!, liquidSplit.holders.map((holder) => { return holder.recipient }), ) } return liquidSplit } private async formatSplit(gqlSplit: ISplit): Promise<Split> { const split = protectedFormatSplit(gqlSplit) if (this._includeEnsNames) { if (!this._ensPublicClient) throw new Error() const ensRecipients = split.recipients .map((recipient) => { return recipient.recipient }) .concat(split.controller ? [split.controller] : []) .concat( split.newPotentialController ? [split.newPotentialController] : [], ) await addEnsNames(this._ensPublicClient, ensRecipients) } return split } }