UNPKG

@fleupold/dex-contracts

Version:

Contracts for dFusion multi-token batch auction exchange

215 lines (214 loc) 9.12 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 { BatchExchangeArtifact, } from "../contracts"; import { AuctionState } from "./state"; /** * The default orderbook options. */ export const DEFAULT_ORDERBOOK_OPTIONS = { 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 { constructor(web3, contract, startBlock, options) { this.web3 = web3; this.contract = contract; this.startBlock = startBlock; this.options = options; this.batch = -1; 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. */ static async init(web3, options = {}) { const [contract, tx] = await deployment(web3, BatchExchangeArtifact); 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. */ getOpenOrders() { 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. */ async applyPastEvents() { 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. */ async update(toBlock) { 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. */ async getPastEvents(options) { const events = await this.contract.getPastEvents("allEvents", { toBlock: "latest", ...options, }); return events; } /** * 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. */ async getBatchId(blockNumber) { 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. */ throwOnInvalidState() { 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(block, inner) { super(`invalid auction state at block ${block}: ${inner.message}`); this.block = block; this.inner = inner; } } /** * 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(web3, { abi, networks }) { 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, tx]; }