@fleupold/dex-contracts
Version:
Contracts for dFusion multi-token batch auction exchange
487 lines (435 loc) • 15.3 kB
text/typescript
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;
}