@0xsplits/splits-sdk
Version:
SDK for the 0xSplits protocol
642 lines (591 loc) • 18.4 kB
text/typescript
import { Address, getAddress, getContract, GetLogsReturnType } from 'viem'
import { SplitsPublicClient, SplitV2Type } from '../types'
import {
getSplitV2FactoriesStartBlock,
getSplitV2FactoryAddress,
INVALID_BLOCK_NUMBER_CHAIN_IDS,
} from '../constants'
import { splitV2FactoryABI } from '../constants/abi/splitV2Factory'
import { splitMainPolygonAbi, splitV2ABI } from '../constants/abi'
import { sleep } from '.'
import { AccountNotFoundError } from '../errors'
import { SplitV2Versions } from '../subgraph/types'
import { splitV2o1Abi } from '../constants/abi/splitV2o1'
/**
* Retries a function n number of times with exponential backoff before giving up
*/
export async function retryExponentialBackoff<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends (...arg0: any[]) => any,
>(
fn: T,
args: Parameters<T>,
maxTry: number,
retryCount = 1,
): Promise<ReturnType<T>> {
const currRetry = typeof retryCount === 'number' ? retryCount : 1
try {
const result = await fn(...args)
return result
} catch (e) {
if (currRetry >= maxTry) {
throw e
}
await delay(1000 * Math.pow(2, retryCount - 1))
return retryExponentialBackoff(fn, args, maxTry, currRetry + 1)
}
}
const delay: (timeoutMs: number) => void = async (timeoutMs) => {
await new Promise((resolve) =>
// Add a random 0 - 100 ms to timeout to avoid requests syncing up
setTimeout(resolve, timeoutMs + getRandomTimeMs(100)),
)
}
const getRandomTimeMs: (maxMs: number) => number = (maxMs) => {
return Math.random() * maxMs
}
// Return true if the public client supports a large enough logs request to fetch erc20 tranfer history
export const isLogsPublicClient = (
publicClient: SplitsPublicClient,
): boolean => {
return (
isAlchemyPublicClient(publicClient) || isInfuraPublicClient(publicClient)
)
}
export const isAlchemyPublicClient: (arg0: SplitsPublicClient) => boolean = (
rpcPublicClient,
) => {
if (rpcPublicClient.transport?.url?.includes('.alchemy.')) return true
if (rpcPublicClient.transport?.url?.includes('.alchemyapi.')) return true
return false
}
export const isInfuraPublicClient: (arg0: SplitsPublicClient) => boolean = (
rpcPublicClient,
) => {
if (rpcPublicClient.transport?.url?.includes('.infura.')) return true
return false
}
// Returns the block ranges in reverse order, so the end block is in the first
// range and the start block is in the last range
const getReverseBlockRanges = (
startBlock: bigint,
endBlock: bigint,
stepSize: bigint,
) => {
const blockRanges = []
let currentBlockNumber = endBlock
// eslint-disable-next-line no-loops/no-loops
while (currentBlockNumber > startBlock) {
const nextBlockNumber =
currentBlockNumber - stepSize > startBlock
? currentBlockNumber - stepSize
: startBlock
blockRanges.push({ from: nextBlockNumber, to: currentBlockNumber })
currentBlockNumber = nextBlockNumber
}
return blockRanges
}
export const getLargestValidBlockRange = async ({
maxBlockRange,
publicClient,
}: {
maxBlockRange?: bigint
publicClient: SplitsPublicClient
}) => {
const fallbackBlockRange = BigInt(625)
const chainId = publicClient.chain!.id
const startBlockNumber = getSplitV2FactoriesStartBlock(chainId)
const blockRangeOptions = [
BigInt(1_000_000),
BigInt(10_000),
BigInt(5_000),
BigInt(1_800),
].filter((range) => (maxBlockRange ? range < maxBlockRange : true))
const blockRangeTests = await Promise.allSettled(
blockRangeOptions.map((testBlockRange) =>
publicClient.getLogs({
events: [splitV2FactoryABI[8]],
address: [
getSplitV2FactoryAddress(chainId, SplitV2Type.Pull),
getSplitV2FactoryAddress(chainId, SplitV2Type.Push),
],
strict: true,
fromBlock: startBlockNumber,
toBlock: startBlockNumber + BigInt(testBlockRange),
}),
),
)
let blockRange = fallbackBlockRange
blockRangeTests.forEach((result, index) => {
if (result.status === 'fulfilled') {
if (blockRangeOptions[index] > blockRange) {
blockRange = blockRangeOptions[index]
}
}
})
return blockRange
}
type SplitCreatedEventType =
| (typeof splitV2FactoryABI)[8]
| (typeof splitMainPolygonAbi)[14]
type SplitUpdatedEventType =
| (typeof splitV2ABI)[28]
| (typeof splitMainPolygonAbi)[18]
export const getSplitCreateAndUpdateLogs = async <
SplitCreatedEventName extends SplitCreatedEventType['name'],
SplitUpdatedEventName extends SplitUpdatedEventType['name'],
SplitCreatedLogType extends GetLogsReturnType<
SplitCreatedEventType,
[SplitCreatedEventType],
true,
bigint,
bigint,
SplitCreatedEventName
>[0] = GetLogsReturnType<
SplitCreatedEventType,
[SplitCreatedEventType],
true,
bigint,
bigint,
SplitCreatedEventName
>[0],
SplitUpdatedLogType extends GetLogsReturnType<
SplitUpdatedEventType,
[SplitUpdatedEventType],
true,
bigint,
bigint,
SplitUpdatedEventName
>[0] = GetLogsReturnType<
SplitUpdatedEventType,
[SplitUpdatedEventType],
true,
bigint,
bigint,
SplitUpdatedEventName
>[0],
>({
splitAddress,
publicClient,
splitCreatedEvent,
splitUpdatedEvent,
addresses,
startBlockNumber,
defaultBlockRange,
currentUpdateLog,
currentEndBlockNumber,
maxBlockRange,
cachedBlocks,
splitV2Version,
}: {
splitAddress: Address
publicClient: SplitsPublicClient
splitCreatedEvent: SplitCreatedEventType
splitUpdatedEvent: SplitUpdatedEventType
addresses: Address[]
startBlockNumber: bigint
defaultBlockRange?: bigint // if this exists, don't need to calculate block range
currentUpdateLog?: SplitUpdatedLogType
currentEndBlockNumber?: bigint
maxBlockRange?: bigint // if this exists, restricts which ranges we will check at the beginning
cachedBlocks?: {
createBlock?: bigint
updateBlock?: bigint
latestScannedBlock: bigint
}
splitV2Version?: SplitV2Versions
}): Promise<{
blockRange: bigint
createLog?: SplitCreatedLogType
updateLog?: SplitUpdatedLogType
}> => {
const formattedSplitAddress = getAddress(splitAddress)
let createLog: SplitCreatedLogType | undefined = undefined
let updateLog: SplitUpdatedLogType | undefined = currentUpdateLog
const endBlock =
currentEndBlockNumber ?? (await publicClient.getBlockNumber())
const startBlock =
cachedBlocks?.latestScannedBlock ??
cachedBlocks?.createBlock ??
startBlockNumber
const {
blockRange,
createLog: searchCreateLog,
updateLog: searchUpdateLog,
} = await searchLogs<
SplitCreatedEventName,
SplitUpdatedEventName,
SplitCreatedLogType,
SplitUpdatedLogType
>({
formattedSplitAddress,
publicClient,
addresses,
splitCreatedEvent,
splitUpdatedEvent,
startBlock,
endBlock,
defaultBlockRange,
maxBlockRange,
splitV2Version,
})
createLog = searchCreateLog
updateLog = searchUpdateLog
if (!createLog) {
if (cachedBlocks?.createBlock) {
try {
const logs = await publicClient.getLogs({
events: [splitCreatedEvent],
address: addresses,
strict: true,
fromBlock: cachedBlocks.createBlock,
toBlock: cachedBlocks.createBlock,
})
// eslint-disable-next-line no-loops/no-loops
for (const log of logs) {
if (getAddress(log.args.split) === formattedSplitAddress) {
if (createLog) throw new Error('Found multiple create split logs')
createLog = log as SplitCreatedLogType
}
}
} catch (error) {
if (!(error instanceof Error)) throw error
return await handleLogsError({
error,
callback: async ({ defaultBlockRange, maxBlockRange }) => {
return await getSplitCreateAndUpdateLogs<
SplitCreatedEventName,
SplitUpdatedEventName,
SplitCreatedLogType,
SplitUpdatedLogType
>({
splitAddress,
publicClient,
defaultBlockRange,
maxBlockRange,
currentUpdateLog: updateLog,
currentEndBlockNumber: startBlock,
splitCreatedEvent,
splitUpdatedEvent,
addresses,
startBlockNumber,
cachedBlocks,
splitV2Version,
})
},
blockRange,
})
}
}
if (!createLog && splitV2Version === 'splitV2')
throw new AccountNotFoundError(
'Split',
formattedSplitAddress,
publicClient.chain!.id,
)
}
if (!updateLog) {
if (cachedBlocks?.updateBlock) {
try {
const logs = await publicClient.getLogs({
events: [splitUpdatedEvent],
address: addresses,
strict: true,
fromBlock: cachedBlocks.createBlock,
toBlock: cachedBlocks.createBlock,
})
// eslint-disable-next-line no-loops/no-loops
for (const log of logs) {
if (log.eventName === 'SplitUpdated') {
const shouldSet =
getAddress(log.address) === formattedSplitAddress &&
(!updateLog ||
log.blockNumber > updateLog.blockNumber ||
(log.blockNumber === updateLog.blockNumber &&
log.logIndex > updateLog.logIndex))
if (shouldSet) updateLog = log as SplitUpdatedLogType
} else if (log.eventName === 'UpdateSplit') {
const shouldSet =
getAddress(log.args.split) === formattedSplitAddress &&
(!updateLog ||
log.blockNumber > updateLog.blockNumber ||
(log.blockNumber === updateLog.blockNumber &&
log.logIndex > updateLog.logIndex))
if (shouldSet) updateLog = log as SplitUpdatedLogType
}
}
} catch (error) {
if (!(error instanceof Error)) throw error
return await handleLogsError({
error,
callback: async ({ defaultBlockRange, maxBlockRange }) => {
return await getSplitCreateAndUpdateLogs<
SplitCreatedEventName,
SplitUpdatedEventName,
SplitCreatedLogType,
SplitUpdatedLogType
>({
splitAddress,
publicClient,
defaultBlockRange,
maxBlockRange,
currentUpdateLog: updateLog,
currentEndBlockNumber: startBlock,
splitCreatedEvent,
splitUpdatedEvent,
addresses,
startBlockNumber,
cachedBlocks,
splitV2Version,
})
},
blockRange,
})
}
}
}
return {
blockRange,
createLog,
updateLog,
}
}
export const LOGS_SEARCH_BATCH_SIZE = 10
export const searchLogs = async <
SplitCreatedEventName extends SplitCreatedEventType['name'],
SplitUpdatedEventName extends SplitUpdatedEventType['name'],
SplitCreatedLogType extends GetLogsReturnType<
SplitCreatedEventType,
[SplitCreatedEventType],
true,
bigint,
bigint,
SplitCreatedEventName
>[0],
SplitUpdatedLogType extends GetLogsReturnType<
SplitUpdatedEventType,
[SplitUpdatedEventType],
true,
bigint,
bigint,
SplitUpdatedEventName
>[0],
>({
formattedSplitAddress,
publicClient,
addresses,
splitCreatedEvent,
splitUpdatedEvent,
startBlock,
endBlock,
defaultBlockRange,
maxBlockRange,
currentUpdateLog,
cachedBlocks,
splitV2Version,
}: {
formattedSplitAddress: Address
publicClient: SplitsPublicClient
splitCreatedEvent: SplitCreatedEventType
splitUpdatedEvent: SplitUpdatedEventType
addresses: Address[]
startBlock: bigint
endBlock: bigint
defaultBlockRange?: bigint // if this exists, don't need to calculate block range
currentUpdateLog?: SplitUpdatedLogType
// currentEndBlockNumber?: bigint
maxBlockRange?: bigint // if this exists, restricts which ranges we will check at the beginning
cachedBlocks?: {
createBlock: bigint
updateBlock?: bigint
latestScannedBlock: bigint
}
splitV2Version?: SplitV2Versions
}): Promise<{
blockRange: bigint
createLog?: SplitCreatedLogType
updateLog?: SplitUpdatedLogType
}> => {
let createLog: SplitCreatedLogType | undefined = undefined
let updateLog: SplitUpdatedLogType | undefined = currentUpdateLog
if (
splitV2Version === 'splitV2o1' &&
!INVALID_BLOCK_NUMBER_CHAIN_IDS.includes(publicClient.chain?.id)
) {
const splitContract = getContract({
address: formattedSplitAddress,
abi: splitV2o1Abi,
client: publicClient,
})
const blockNumber = await splitContract.read.updateBlockNumber()
if (currentUpdateLog && blockNumber === currentUpdateLog.blockNumber) {
return {
blockRange: BigInt(1),
updateLog,
createLog,
}
} else {
const logs = await publicClient.getLogs({
events: [splitUpdatedEvent],
address: [formattedSplitAddress],
strict: true,
fromBlock: blockNumber,
toBlock: blockNumber,
})
logs.forEach((log) => {
if (log.eventName === 'SplitUpdated') {
const shouldSet =
getAddress(log.address) === formattedSplitAddress &&
(!updateLog ||
log.blockNumber > updateLog.blockNumber ||
(log.blockNumber === updateLog.blockNumber &&
log.logIndex > updateLog.logIndex))
if (shouldSet) updateLog = log as SplitUpdatedLogType
}
})
return {
blockRange: BigInt(1),
updateLog,
createLog,
}
}
}
let blockRange
if (defaultBlockRange) blockRange = defaultBlockRange
else {
// Try to determine the largest possible block range. Sometimes these rpc's do not always
// throw a block range error though...so that means this request could succeed, but then down
// below we will get a block range error. So we still need to catch/handle that down below.
blockRange = await getLargestValidBlockRange({
maxBlockRange,
publicClient,
})
}
const searchBlockRanges = getReverseBlockRanges(
startBlock,
endBlock,
blockRange,
)
let batchRequests = []
let lastBlockInBatch: bigint | undefined = undefined
// eslint-disable-next-line no-loops/no-loops
for (const [index, { from, to }] of searchBlockRanges.entries()) {
if (!lastBlockInBatch) lastBlockInBatch = to
batchRequests.push(
publicClient.getLogs({
events: [splitCreatedEvent, splitUpdatedEvent],
address: addresses,
strict: true,
fromBlock: from,
toBlock: to,
}),
)
const shouldAwait =
batchRequests.length >= LOGS_SEARCH_BATCH_SIZE ||
index === searchBlockRanges.length - 1
if (shouldAwait) {
try {
const results = (await Promise.all(batchRequests)).flat()
// eslint-disable-next-line no-loops/no-loops
for (const log of results) {
if (log.eventName === 'SplitUpdated') {
const shouldSet =
getAddress(log.address) === formattedSplitAddress &&
(!updateLog ||
log.blockNumber > updateLog.blockNumber ||
(log.blockNumber === updateLog.blockNumber &&
log.logIndex > updateLog.logIndex))
if (shouldSet) updateLog = log as SplitUpdatedLogType
} else if (log.eventName === 'UpdateSplit') {
const shouldSet =
getAddress(log.args.split) === formattedSplitAddress &&
(!updateLog ||
log.blockNumber > updateLog.blockNumber ||
(log.blockNumber === updateLog.blockNumber &&
log.logIndex > updateLog.logIndex))
if (shouldSet) updateLog = log as SplitUpdatedLogType
} else {
if (getAddress(log.args.split) === formattedSplitAddress) {
if (createLog) throw new Error('Found multiple create split logs')
createLog = log as SplitCreatedLogType
}
}
}
} catch (error) {
if (!(error instanceof Error)) throw error
return await handleLogsError({
error,
callback: async ({ defaultBlockRange, maxBlockRange }) => {
return await searchLogs<
SplitCreatedEventName,
SplitUpdatedEventName,
SplitCreatedLogType,
SplitUpdatedLogType
>({
formattedSplitAddress,
publicClient,
addresses,
splitCreatedEvent,
splitUpdatedEvent,
startBlock,
endBlock: lastBlockInBatch!,
defaultBlockRange,
maxBlockRange,
currentUpdateLog: updateLog,
cachedBlocks,
})
},
blockRange,
})
}
if (createLog) break
if (cachedBlocks?.createBlock && updateLog) break
batchRequests = []
lastBlockInBatch = undefined
}
}
return { blockRange, createLog, updateLog }
}
const handleLogsError = async <CallbackReturn>({
error,
callback,
blockRange,
}: {
error: Error
callback: (args: {
defaultBlockRange?: bigint
maxBlockRange?: bigint
}) => Promise<CallbackReturn>
blockRange: bigint
}) => {
const sleepTimeMs = 10_000
// Handle rate limit error
if ('status' in error && error.status === 429) {
await sleep(sleepTimeMs)
return await callback({
defaultBlockRange: blockRange,
})
}
// Handle block range errors
if ('details' in error && typeof error.details === 'string') {
const lowerCaseDetails = error.details.toLowerCase()
if (
lowerCaseDetails.includes('block') &&
lowerCaseDetails.includes('range')
) {
return await callback({
maxBlockRange: blockRange,
})
}
}
const lowerCaseMessage = error.message.toLowerCase()
if (
lowerCaseMessage.includes('block') &&
lowerCaseMessage.includes('range')
) {
return await callback({
maxBlockRange: blockRange,
})
}
throw error
}