UNPKG

@fleupold/dex-contracts

Version:

Contracts for dFusion multi-token batch auction exchange

487 lines (435 loc) 15.3 kB
import { Fraction, FractionJson } from "./fraction"; import type BN from "bn.js"; export class Offer { price: Fraction; volume: Fraction; constructor(price: Fraction, volume: number | Fraction) { if (typeof volume == "number") { this.volume = new Fraction(volume, 1); } else { this.volume = volume as Fraction; } this.price = price; } clone(): Offer { return new Offer(this.price.clone(), this.volume.clone()); } static fromJSON(o: OfferJson): Offer { return new Offer(Fraction.fromJSON(o.price), Fraction.fromJSON(o.volume)); } } export interface OfferJson { price: FractionJson; volume: FractionJson; } type Fee = { fee: Fraction }; type RemainingFractionAfterFee = { remainingFractionAfterFee: Fraction }; export class Orderbook { readonly baseToken: string; readonly quoteToken: string; readonly remainingFractionAfterFee: Fraction; private asks: Map<number, Offer>; // Mapping from price to cumulative offers at this point. private bids: Map<number, Offer>; // Mapping from price to cumulative offers at this point. constructor( baseToken: string | number, quoteToken: string | number, options: Fee | RemainingFractionAfterFee = { fee: new Fraction(1, 1000) }, ) { this.baseToken = baseToken.toString(); this.quoteToken = quoteToken.toString(); if ("fee" in options) { this.remainingFractionAfterFee = new Fraction(1, 1).sub(options.fee); } else { this.remainingFractionAfterFee = options.remainingFractionAfterFee; } this.asks = new Map(); this.bids = new Map(); } getOffers(): { bids: Offer[]; asks: Offer[] } { const asks = Array.from(this.asks.values()); const bids = Array.from(this.bids.values()); asks.sort(sortOffersAscending); bids.sort(sortOffersDescending); return { bids, asks }; } toJSON(): OrderbookToJson { return { baseToken: this.baseToken, quoteToken: this.quoteToken, remainingFractionAfterFee: this.remainingFractionAfterFee, asks: offersToJSON(this.asks), bids: offersToJSON(this.bids), }; } static fromJSON(o: OrderbookJson): Orderbook { const remainingFractionAfterFee = Fraction.fromJSON( o.remainingFractionAfterFee, ); const result = new Orderbook(o.baseToken, o.quoteToken, { remainingFractionAfterFee, }); result.asks = offersFromJSON(o.asks); result.bids = offersFromJSON(o.bids); return result; } pair(): string { return `${this.baseToken}/${this.quoteToken}`; } addBid(bid: Offer): void { // For bids the effective price after fee becomes smaller const offer = new Offer( bid.price.mul(this.remainingFractionAfterFee), bid.volume.mul(this.remainingFractionAfterFee), ); addOffer(offer, this.bids); } addAsk(ask: Offer): void { // For asks the effective price after fee becomes larger const offer = new Offer( ask.price.div(this.remainingFractionAfterFee), ask.volume.mul(this.remainingFractionAfterFee), ); addOffer(offer, this.asks); } /** * @returns the inverse of the current order book (e.g. ETH/DAI becomes DAI/ETH) * by switching bids/asks and recomputing price/volume to the new reference token. */ inverted(): Orderbook { const result = new Orderbook(this.quoteToken, this.baseToken, { fee: this.fee(), }); result.bids = invertPricePoints(this.asks, this.remainingFractionAfterFee); result.asks = invertPricePoints( this.bids, this.remainingFractionAfterFee.inverted(), ); return result; } /** * In-place adds the given orderbook to the current one, combining all bids and asks at the same price point * @param orderbook - the orderbook to be added to this one */ add(orderbook: Orderbook): void { if (orderbook.pair() != this.pair()) { throw new Error( `Cannot add ${orderbook.pair()} orderbook to ${this.pair()} orderbook`, ); } orderbook.bids.forEach((bid) => { addOffer(bid, this.bids); }); orderbook.asks.forEach((ask) => { addOffer(ask, this.asks); }); } /** * @param amount - the amount of base tokens to be sold * @returns the price for which there are enough bids to fill the specified amount or undefined if there is not enough liquidity */ priceToSellBaseToken(amount: number | BN): Fraction | undefined { const bids = Array.from(this.bids.values()); bids.sort(sortOffersDescending); const price_before_fee = priceToCoverAmount(new Fraction(amount, 1), bids); // Price to sell base token after fee will be lower return price_before_fee?.mul(this.remainingFractionAfterFee); } /** * @param amount - the amount of base tokens to be bought * @returns the price for which there are enough asks to fill the specified amount or undefined if there is not enough liquidity */ priceToBuyBaseToken(amount: number | BN): Fraction | undefined { const asks = Array.from(this.asks.values()); asks.sort(sortOffersAscending); const price_before_fee = priceToCoverAmount(new Fraction(amount, 1), asks); // Price to buy base token after fee will be higher return price_before_fee?.div(this.remainingFractionAfterFee); } /** * Removes any overlapping bid/asks which could be matched in the current orderbook * @returns A new instance of the orderbook with no more overlapping orders. */ reduced(): Orderbook { const result = new Orderbook(this.baseToken, this.quoteToken); const bids = Array.from(this.bids.values()); bids.sort(sortOffersDescending); const asks = Array.from(this.asks.values()); asks.sort(sortOffersAscending); const bid_iterator = bids.values(); const ask_iterator = asks.values(); let best_bid = bid_iterator.next(); let best_ask = ask_iterator.next(); while ( !(best_bid.done || best_ask.done) && !best_bid.value.price.lt(best_ask.value.price) ) { // We have an overlapping bid/ask. Subtract the smaller from the larger and remove the smaller if (best_bid.value.volume.gt(best_ask.value.volume)) { best_bid.value = new Offer( best_bid.value.price, best_bid.value.volume.sub(best_ask.value.volume), ); best_ask = ask_iterator.next(); } else { best_ask.value = new Offer( best_ask.value.price, best_ask.value.volume.sub(best_bid.value.volume), ); best_bid = bid_iterator.next(); // In case the orders matched perfectly we will move ask as well if (best_ask.value.volume.isZero()) { best_ask = ask_iterator.next(); } } } //Add remaining bids/asks to result while (!best_ask.done) { addOffer(best_ask.value, result.asks); best_ask = ask_iterator.next(); } while (!best_bid.done) { addOffer(best_bid.value, result.bids); best_bid = bid_iterator.next(); } return result; } /** * Computes the transitive closure of this orderbook (e.g. ETH/DAI) with another one (e.g. DAI/USDC). * Throws if the orderbooks cannot be combined (baseToken is not equal to quoteToken) * @param orderbook - The orderbook for which the transitive closure will be computed * @returns A new instance of an orderbook representing the resulting closure. */ transitiveClosure(orderbook: Orderbook): Orderbook { if (orderbook.baseToken != this.quoteToken) { throw new Error( `Cannot compute transitive closure of ${this.pair()} orderbook and ${orderbook.pair()} orderbook`, ); } const ask_closure = this.transitiveAskClosure(orderbook); // Since bids are the asks of the inverted orderbook, computing transitive closure of bids is equivalent to // 1) inverting both orderbooks // 2) computing the transitive ask closure on the inverses // 3) re-inverting the result const bid_closure = orderbook .inverted() .transitiveAskClosure(this.inverted()) .inverted(); ask_closure.add(bid_closure); return ask_closure; } private transitiveAskClosure(orderbook: Orderbook): Orderbook { const result = new Orderbook(this.baseToken, orderbook.quoteToken, { fee: this.fee(), }); // Create a copy here so original orders stay untouched const left_asks = Array.from(this.asks.values(), (o) => o.clone()); const right_asks = Array.from(orderbook.asks.values(), (o) => o.clone()); left_asks.sort(sortOffersAscending); right_asks.sort(sortOffersAscending); const left_iterator = left_asks.values(); const right_iterator = right_asks.values(); let right_next = right_iterator.next(); let left_next = left_iterator.next(); while (!(left_next.done || right_next.done)) { const right_offer = right_next.value; const left_offer = left_next.value; const price = left_offer.price.mul(right_offer.price); let volume; const right_offer_volume_in_left_offer_base_token = right_offer.volume.div( left_offer.price, ); if (left_offer.volume.gt(right_offer_volume_in_left_offer_base_token)) { volume = right_offer_volume_in_left_offer_base_token; left_offer.volume = left_offer.volume.sub(volume); right_next = right_iterator.next(); } else { volume = left_offer.volume; right_offer.volume = right_offer.volume.sub( volume.mul(left_offer.price), ); left_next = left_iterator.next(); // In case the orders matched perfectly we will move right as well if (right_offer.volume.isZero()) { right_next = right_iterator.next(); } } addOffer(new Offer(price, volume), result.asks); } return result; } fee(): Fraction { return new Fraction(1, 1).sub(this.remainingFractionAfterFee); } clone(): Orderbook { const result = new Orderbook(this.baseToken, this.quoteToken, { fee: this.fee(), }); this.bids.forEach((o) => addOffer(o, result.bids)); this.asks.forEach((o) => addOffer(o, result.asks)); return result; } } export interface OrderbookToJson { baseToken: string; quoteToken: string; remainingFractionAfterFee: Fraction; asks: Record<string, Offer>; bids: Record<string, Offer>; } export interface OrderbookJson { baseToken: string; quoteToken: string; remainingFractionAfterFee: FractionJson; asks: Record<string, OfferJson>; bids: Record<string, OfferJson>; } /** * Given a list of direct orderbooks this method returns the transitive orderbook * between two tokens by computing the transitive closure via a certain number of "hops". * @param direct_orderbooks - the map direct (non-transitive) orderbooks between tokens * @param base - the base token for which the transitive orderbook should be computed * @param quote - the quote token for which the transitive orderbook should be computed * @param hops - the number of intermediate tokens that should be considered when computing the transitive orderbook */ export function transitiveOrderbook( direct_orderbooks: Map<string, Orderbook>, base: string, quote: string, hops: number, ): Orderbook { const complete_orderbooks = new Map(); direct_orderbooks.forEach((book) => { complete_orderbooks.set(book.pair(), book.clone()); // If inverse pair doesn't exist we will create an empty one if (!direct_orderbooks.has(book.inverted().pair())) { const empty_book = new Orderbook(book.quoteToken, book.baseToken); complete_orderbooks.set(empty_book.pair(), empty_book); } }); // Merge bid/ask orderbooks complete_orderbooks.forEach((book, pair) => { const inverse = book.inverted(); const inverse_pair = inverse.pair(); // Only update one of the two sides if (pair > inverse_pair) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion complete_orderbooks.get(inverse_pair)!.add(inverse); complete_orderbooks.set( pair, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion complete_orderbooks.get(inverse_pair)!.inverted(), ); } }); return transitiveOrderbookRecursive( complete_orderbooks, base, quote, hops, [], ); } function transitiveOrderbookRecursive( orderbooks: Map<string, Orderbook>, base: string, quote: string, hops: number, ignore: string[], ): Orderbook { const result = new Orderbook(base, quote); // Add the direct book if it exists const orderbook = orderbooks.get(result.pair()); if (orderbook) { result.add(orderbook); } if (hops === 0) { return result; } // Check for each orderbook that starts with same baseToken, if there exists a connecting book. // If yes, build transitive closure orderbooks.forEach((book) => { if ( book.baseToken === base && !(book.quoteToken === quote) && !ignore.includes(book.quoteToken) ) { const otherBook = transitiveOrderbookRecursive( orderbooks, book.quoteToken, quote, hops - 1, ignore.concat(book.baseToken), ); const closure = book.transitiveClosure(otherBook); result.add(closure); } }); return result; } function addOffer(offer: Offer, existingOffers: Map<number, Offer>): void { const price = offer.price.toNumber(); let current_offer_at_price; let current_volume_at_price = new Fraction(0, 1); if ((current_offer_at_price = existingOffers.get(price))) { current_volume_at_price = current_offer_at_price.volume; } existingOffers.set( price, new Offer(offer.price, offer.volume.add(current_volume_at_price)), ); } function sortOffersAscending(left: Offer, right: Offer): number { if (left.price.gt(right.price)) { return 1; } else if (left.price.lt(right.price)) { return -1; } else { return 0; } } function sortOffersDescending(left: Offer, right: Offer): number { return sortOffersAscending(left, right) * -1; } function priceToCoverAmount( amount: Fraction, offers: Offer[], ): Fraction | undefined { for (const offer of offers) { if (offer.volume.lt(amount)) { amount = amount.sub(offer.volume); } else { return offer.price; } } return undefined; } function invertPricePoints( prices: Map<number, Offer>, priceAdjustmentForFee: Fraction, ): Map<number, Offer> { return new Map( Array.from(prices.entries()).map(([, offer]) => { const inverted_price = offer.price.inverted(); const price_before_fee = offer.price.mul(priceAdjustmentForFee); const inverted_volume = offer.volume.mul(price_before_fee); return [ inverted_price.toNumber(), new Offer(inverted_price, inverted_volume), ]; }), ); } function offersFromJSON(o: Record<string, OfferJson>): Map<number, Offer> { const offers = new Map(); for (const [key, value] of Object.entries(o)) { offers.set(key, Offer.fromJSON(value)); } return offers; } function offersToJSON(offers: Map<number, Offer>): Record<string, Offer> { const o: Record<string, Offer> = {}; offers.forEach((value, key) => { o[key] = value; }); return o; }