UNPKG

@fleupold/dex-contracts

Version:

Contracts for dFusion multi-token batch auction exchange

333 lines (295 loc) 10.9 kB
/** * Module containing event based orderbook reading implementation. The entire * orderbook is contructed from Ethreum events fetched in two stages: first, * the past events are fetched in block-based pages, second an event subsciption * is setup to listen to new incomming events that get added to the orderbook * snapshot. * * The streamed orderbook can be queried at any time to get the current open * orders (that is orders and balances as it would they would be included for * the next batch) or the finalized orderbook (orders and balances that are * being considered for trading by the solver). * * @packageDocumentation */ import type Web3 from "web3"; import type { BlockNumber, TransactionReceipt } from "web3-core"; import type { BaseContract } from "../../build/types/types"; import { BatchExchange, BatchExchangeArtifact, ContractArtifact, } from "../contracts"; import { IndexedOrder } from "../encoding"; import { AnyEvent } from "./events"; import { AuctionState } from "./state"; /** * Configuration options for the streamed orderbook. */ export interface OrderbookOptions { /** * Optinally specify the last block to apply events for. This is useful for * testing the streamed orderbook and ensuring that it is producing a correct * account state for a known block. */ endBlock?: number; /** * Set the block page size used to query past events. * * @remarks * Nodes are usually configured to limit the number of events that can be * returned by a single call to retrieve past logs (10,000 for Infura) * so this parameter should be set accordingly. */ blockPageSize: number; /** * Sets the number of block confirmations required for an event to be * considered confirmed and not be subject to re-orgs. */ blockConfirmations: number; /** * Enable strict checking that performs additional integrity checks. * * @remarks * The additional integrity checks have a non-negligible runtime cost so * they are disabled by default, but can help diagnose issues and bugs in the * streamed orderbook. */ strict: boolean; /** * Set logging function for debug messages used by the streamed orderbook * module. */ debug: (msg: string) => void; } /** * The default orderbook options. */ export const DEFAULT_ORDERBOOK_OPTIONS: OrderbookOptions = { blockPageSize: 10000, blockConfirmations: 6, strict: false, debug: () => { return; }, }; /** * The streamed orderbook that manages incoming events, and applies them to the * account state. */ export class StreamedOrderbook { private batch = -1; private readonly confirmedState: AuctionState; private latestState?: AuctionState; private invalidState?: InvalidAuctionStateError; private constructor( private readonly web3: Web3, private readonly contract: BatchExchange, private readonly startBlock: number, private readonly options: OrderbookOptions, ) { this.confirmedState = new AuctionState(options); } /** * Create and return a new streamed orderbook. * * @remarks * This method returns a promise that resolves once all past events have been * applied to the current account state and the orderbook. * * @param web3 - The web3 provider to use. * @param options - Optional settings for tweaking the streamed orderbook. */ public static async init( web3: Web3, options: Partial<OrderbookOptions> = {}, ): Promise<StreamedOrderbook> { const [contract, tx] = await deployment<BatchExchange>( web3, BatchExchangeArtifact as ContractArtifact, ); const orderbook = new StreamedOrderbook(web3, contract, tx.blockNumber, { ...DEFAULT_ORDERBOOK_OPTIONS, ...options, }); await orderbook.applyPastEvents(); if (orderbook.options.endBlock === undefined) { await orderbook.update(); } return orderbook; } /** * Retrieves the current open orders in the orderbook. */ public getOpenOrders(): IndexedOrder<bigint>[] { this.throwOnInvalidState(); const state = this.latestState ?? this.confirmedState; return state.getOrders(this.batch); } /** * Apply all past events to the account state by querying the node for past * events with multiple queries to retrieve each block page at a time. */ private async applyPastEvents(): Promise<void> { const endBlock = this.options.endBlock ?? (await this.web3.eth.getBlockNumber()) - this.options.blockConfirmations; for ( let fromBlock = this.startBlock; fromBlock < endBlock; fromBlock += this.options.blockPageSize ) { // NOTE: `getPastEvents` block range is inclusive. const toBlock = Math.min( fromBlock + this.options.blockPageSize - 1, endBlock, ); this.options.debug(`fetching past events from ${fromBlock}-${toBlock}`); const events = await this.getPastEvents({ fromBlock, toBlock }); this.options.debug(`applying ${events.length} past events`); this.confirmedState.applyEvents(events); } this.batch = await this.getBatchId(endBlock); } /** * Apply new confirmed events to the account state and store the remaining * events that are subject to reorgs into the `pendingEvents` array. * * @param toBlock - Optional block number until which to fetch events, capped at latest block number. * @returns The block number up until which the streamed orderbook is up to * date * * @remarks * If there is an error retrieving the latest events from the node, then the * account state remains unmodified. This allows the updating orderbook to be * more fault-tolerant and deal with nodes being temporarily down and some * intermittent errors. However, if an error applying confirmed events occur, * then the streamed orderbook becomes invalid and can no longer apply new * events as the actual auction state is unknown. */ public async update(toBlock?: number): Promise<number> { this.throwOnInvalidState(); const fromBlock = this.confirmedState.nextBlock; this.options.debug( `fetching new events from ${fromBlock}-${toBlock ?? "latest"}`, ); const events = await this.getPastEvents({ fromBlock, toBlock }); // NOTE: If the web3 instance is connected to nodes behind a load balancer, // it is possible that the events were queried on a node that includes an // additional block to the node that handled the query to the latest block // number, so use the max of `latestEvents.last().blockNumber` and the // queried latest block number. const latestBlock = Math.max( await this.web3.eth.getBlockNumber(), events[events.length - 1]?.blockNumber ?? 0, ); const confirmedBlock = latestBlock - this.options.blockConfirmations; const endBlock = Math.min(latestBlock, toBlock ?? Number.MAX_SAFE_INTEGER); this.batch = await this.getBatchId(endBlock); if (events.length === 0) { return endBlock; } const firstLatestEvent = events.findIndex( (ev) => ev.blockNumber > confirmedBlock, ); const confirmedEventCount = firstLatestEvent !== -1 ? firstLatestEvent : events.length; const confirmedEvents = events.slice(0, confirmedEventCount); const latestEvents = events.slice(confirmedEventCount); this.options.debug( `applying ${confirmedEvents.length} confirmed events until block ${confirmedBlock}`, ); try { this.confirmedState.applyEvents(confirmedEvents); } catch (err) { this.invalidState = new InvalidAuctionStateError(confirmedBlock, err); this.options.debug(this.invalidState.message); throw this.invalidState; } this.latestState = undefined; this.options.debug( `reapplying ${latestEvents.length} latest events until block ${endBlock}`, ); if (latestEvents.length > 0) { // NOTE: Errors applying latest state are not considered fatal as we can // still recover from them (since the confirmed state is still valid). If // applying the latest events fails, just make sure that the `latestEvent` // property is not set, so that the query methods fall back to using the // confirmed state. const newLatestState = this.confirmedState.copy(); newLatestState.applyEvents(latestEvents); this.latestState = newLatestState; } return endBlock; } /** * Retrieves past events for the contract. */ private async getPastEvents(options: { fromBlock: BlockNumber; toBlock?: BlockNumber; }): Promise<AnyEvent<BatchExchange>[]> { const events = await this.contract.getPastEvents("allEvents", { toBlock: "latest", ...options, }); return events as AnyEvent<BatchExchange>[]; } /** * Retrieves the batch ID at a given block number. * * @remarks * The batch ID is locally calculated from the block header timestamp as it is * more reliable than executing an `eth_call` to calculate the batch ID on the * EVM since an archive node is required for sufficiently old blocks. */ private async getBatchId(blockNumber: BlockNumber): Promise<number> { const BATCH_DURATION = 300; const block = await this.web3.eth.getBlock(blockNumber); // NOTE: Pending or future blocks return null when queried, so approximate // with system time const timestamp = block?.timestamp ?? Date.now(); const batch = Math.floor(Number(timestamp) / BATCH_DURATION); return batch; } /** * Helper method to check for an unrecoverable invalid state in the current * streamed orderbook. * * @throws If the streamed orderbook is in an invalid state. */ private throwOnInvalidState(): void { if (this.invalidState) { throw this.invalidState; } } } /** * An error that is thrown on when the auction state is invalid and can no * longer be updated. */ export class InvalidAuctionStateError extends Error { constructor(public readonly block: number, public readonly inner: Error) { super(`invalid auction state at block ${block}: ${inner.message}`); } } /** * Get a contract deployment, returning both the web3 contract object as well as * the transaction receipt for the contract deployment. * * @throws If the contract is not deployed on the network the web3 provider is * connected to. */ export async function deployment<C extends BaseContract>( web3: Web3, { abi, networks }: ContractArtifact, ): Promise<[C, TransactionReceipt]> { const chainId = await web3.eth.getChainId(); const network = networks[chainId]; if (!networks) { throw new Error(`not deployed on network with chain ID ${chainId}`); } const tx = await web3.eth.getTransactionReceipt(network.transactionHash); const contract = new web3.eth.Contract(abi, network.address); return [(contract as unknown) as C, tx]; }