UNPKG

@fleupold/dex-contracts

Version:

Contracts for dFusion multi-token batch auction exchange

346 lines (345 loc) 14.3 kB
import { Fraction } from "./fraction"; export class Offer { constructor(price, volume) { if (typeof volume == "number") { this.volume = new Fraction(volume, 1); } else { this.volume = volume; } this.price = price; } clone() { return new Offer(this.price.clone(), this.volume.clone()); } static fromJSON(o) { return new Offer(Fraction.fromJSON(o.price), Fraction.fromJSON(o.volume)); } } export class Orderbook { constructor(baseToken, quoteToken, options = { 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() { const asks = Array.from(this.asks.values()); const bids = Array.from(this.bids.values()); asks.sort(sortOffersAscending); bids.sort(sortOffersDescending); return { bids, asks }; } toJSON() { return { baseToken: this.baseToken, quoteToken: this.quoteToken, remainingFractionAfterFee: this.remainingFractionAfterFee, asks: offersToJSON(this.asks), bids: offersToJSON(this.bids), }; } static fromJSON(o) { 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() { return `${this.baseToken}/${this.quoteToken}`; } addBid(bid) { // 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) { // 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() { 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) { 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) { 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) { 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() { 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) { 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; } transitiveAskClosure(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() { return new Fraction(1, 1).sub(this.remainingFractionAfterFee); } clone() { 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; } } /** * 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, base, quote, hops) { 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, base, quote, hops, ignore) { 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, existingOffers) { 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, right) { if (left.price.gt(right.price)) { return 1; } else if (left.price.lt(right.price)) { return -1; } else { return 0; } } function sortOffersDescending(left, right) { return sortOffersAscending(left, right) * -1; } function priceToCoverAmount(amount, offers) { for (const offer of offers) { if (offer.volume.lt(amount)) { amount = amount.sub(offer.volume); } else { return offer.price; } } return undefined; } function invertPricePoints(prices, priceAdjustmentForFee) { 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) { const offers = new Map(); for (const [key, value] of Object.entries(o)) { offers.set(key, Offer.fromJSON(value)); } return offers; } function offersToJSON(offers) { const o = {}; offers.forEach((value, key) => { o[key] = value; }); return o; }