@fleupold/dex-contracts
Version:
Contracts for dFusion multi-token batch auction exchange
654 lines (603 loc) • 18.6 kB
text/typescript
import assert from "assert";
import { EventLog } from "web3-core";
import { ContractEventLog } from "../../build/types/types";
import { BatchExchange } from "../contracts";
import { IndexedOrder } from "../encoding";
import { OrderbookOptions } from ".";
import { AnyEvent, Event } from "./events";
/**
* An ethereum address.
*/
type Address = string;
/**
* An exchange token ID.
*/
type TokenId = number;
/**
* Internal representation of a user account.
*/
interface Account {
/**
* Mapping from a token address to an amount representing the account's total
* balance in the exchange.
*
* @remarks
* Pending withdrawal amounts are not included in this balance although they
* affect the available balance of the account.
*/
balances: Map<Address, bigint>;
/**
* Mapping from a token address to a pending withdrawal.
*/
pendingWithdrawals: Map<Address, PendingWithdrawal>;
/**
* All user orders including valid, invalid, cancelled and deleted orders.
*
* @remarks
* Since user order IDs increase by 1 for each new order, an order can be
* retrieved by ID for an account with `account.orders[orderId]`.
*/
orders: Order[];
}
/**
* Internal representation of a pending withdrawal.
*/
interface PendingWithdrawal {
/**
* The batch ID when the withdrawal request matures, i.e. the amount is no
* longer included in the account's available balance and up to the amount can
* be withdrawn by the user.
*/
batchId: number;
/**
* The requested withdrawal amount.
*/
amount: bigint;
}
/**
* Internal representation of an order.
*/
interface Order {
buyToken: TokenId;
sellToken: TokenId;
validFrom: number;
validUntil: number | null;
priceNumerator: bigint;
priceDenominator: bigint;
remainingAmount: bigint;
}
/**
* Amount used to signal that an order is an unlimited order.
*/
const UNLIMITED_ORDER_AMOUNT = BigInt(2 ** 128) - BigInt(1);
/**
* JSON representation of the account state.
*/
export interface AuctionStateJson {
tokens: string[];
accounts: {
[key: string]: {
balances: { [key: string]: string };
pendingWithdrawals: {
[key: string]: { batchId: number; amount: string };
};
orders: {
buyToken: TokenId;
sellToken: TokenId;
validFrom: number;
validUntil: number | null;
priceNumerator: string;
priceDenominator: string;
remainingAmount: string;
}[];
};
};
}
/**
* Manage the exchange's auction state by incrementally applying events.
*/
export class AuctionState {
private lastBlock = -1;
private readonly tokens: Address[] = [];
private readonly accounts: Map<Address, Account> = new Map();
private lastSolution?: Event<BatchExchange, "SolutionSubmission">;
constructor(private readonly options: OrderbookOptions) {}
/**
* Creates a copy of the auction state that can apply events independently
* without modifying the original state.
*/
public copy(): AuctionState {
const clone = new AuctionState(this.options);
clone.lastBlock = this.lastBlock;
clone.tokens.push(...this.tokens);
for (const [user, account] of this.accounts.entries()) {
clone.accounts.set(user, {
balances: new Map(account.balances),
pendingWithdrawals: new Map(account.pendingWithdrawals),
orders: account.orders.map((order) => ({ ...order })),
});
}
clone.lastSolution = this.lastSolution;
return clone;
}
/**
* Create an object representation of the current account state for JSON
* serialization.
*/
public toJSON(): AuctionStateJson {
function map2obj<K extends { toString: () => string }, V, T>(
map: Map<K, V>,
convert: (value: V) => T,
): { [key: string]: T } {
const result: { [key: string]: T } = {};
for (const [key, value] of map.entries()) {
result[key.toString()] = convert(value);
}
return result;
}
return {
tokens: this.tokens.slice(0),
accounts: map2obj(this.accounts, (account) => ({
balances: map2obj(account.balances, (balance) => balance.toString()),
pendingWithdrawals: map2obj(
account.pendingWithdrawals,
(withdrawal) => ({
...withdrawal,
amount: withdrawal.amount.toString(),
}),
),
orders: account.orders.map((order) => ({
...order,
priceNumerator: order.priceNumerator.toString(),
priceDenominator: order.priceDenominator.toString(),
remainingAmount: order.remainingAmount.toString(),
})),
})),
};
}
/**
* Gets the current auction state in the standard order list format.
*
* @param batch - The batch to get the orders for.
*/
public getOrders(batch: number): IndexedOrder<bigint>[] {
let orders: IndexedOrder<bigint>[] = [];
for (const [user, account] of this.accounts.entries()) {
orders = orders.concat(
account.orders
.map((order, orderId) => ({
...order,
user,
sellTokenBalance: this.getEffectiveBalance(
batch,
user,
order.sellToken,
),
orderId,
validUntil: order.validUntil ?? 0,
}))
.filter(
(order) => order.validFrom <= batch && batch <= order.validUntil,
),
);
}
return orders;
}
/**
* Retrieves a users effective balance for the specified token at a given
* batch.
*
* @param batch - The batch to get the balance for.
* @param user - The user account to retrieve the balance for.
* @param token - The token ID or address to retrieve the balance for.
*/
private getEffectiveBalance(
batch: number,
user: Address,
token: Address | TokenId,
): bigint {
const tokenAddr = this.tokenAddr(token);
const account = this.accounts.get(user);
if (account === undefined) {
return BigInt(0);
}
const balance = account.balances.get(tokenAddr) ?? BigInt(0);
const withdrawal = account.pendingWithdrawals.get(tokenAddr);
const withdrawalAmount =
withdrawal && withdrawal.batchId <= batch ? withdrawal.amount : BigInt(0);
return balance > withdrawalAmount ? balance - withdrawalAmount : BigInt(0);
}
/**
* Retrieves block number the account state is accepting events for.
*/
public get nextBlock(): number {
return this.lastBlock + 1;
}
/**
* Apply all specified ordered events.
*
* @remarks
* This method expects that once events have been applied up until a certain
* block number, then no new events for that block number (or an earlier block
* number) are applied.
*/
public applyEvents(events: AnyEvent<BatchExchange>[]): void {
if (this.options.strict) {
assertEventsAreAfterBlockAndOrdered(this.lastBlock, events);
}
this.lastBlock = events[events.length - 1]?.blockNumber ?? this.lastBlock;
for (const ev of events) {
switch (ev.event) {
case "Deposit":
this.applyDeposit(ev.returnValues);
break;
case "OrderCancellation":
this.applyOrderCancellation(ev.returnValues);
break;
case "OrderDeletion":
this.applyOrderDeletion(ev.returnValues);
break;
case "OrderPlacement":
this.applyOrderPlacement(ev.returnValues);
break;
case "SolutionSubmission":
this.applySolutionSubmission(ev.returnValues);
this.lastSolution = ev.returnValues;
break;
case "TokenListing":
this.applyTokenListing(ev.returnValues);
break;
case "Trade":
this.lastSolution = undefined;
this.applyTrade(ev.returnValues);
break;
case "TradeReversion":
if (this.lastSolution !== undefined) {
this.applySolutionReversion(this.lastSolution);
this.lastSolution = undefined;
}
this.applyTradeReversion(ev.returnValues);
break;
case "Withdraw":
this.applyWithdraw(ev.returnValues);
break;
case "WithdrawRequest":
this.applyWithdrawRequest(ev.returnValues);
break;
default:
throw new UnhandledEventError(ev);
}
}
}
/**
* Applies a deposit event to the auction state.
*/
private applyDeposit({
user,
token,
amount: depositAmount,
}: Event<BatchExchange, "Deposit">): void {
this.updateBalance(user, token, (amount) => amount + BigInt(depositAmount));
}
/**
* Applies an order cancellation event to the auction state.
*/
private applyOrderCancellation({
owner,
id,
}: Event<BatchExchange, "OrderCancellation">): void {
// TODO(nlordell): We need to pre-fetch the batch ID based on the block
// number for this event to be able to accurately set this value.
this.order(owner, parseInt(id)).validUntil = null;
}
/**
* Applies an order deletion event to the auction state.
*/
private applyOrderDeletion({
owner,
id,
}: Event<BatchExchange, "OrderDeletion">): void {
const order = this.order(owner, parseInt(id));
order.buyToken = 0;
order.sellToken = 0;
order.validFrom = 0;
order.validUntil = 0;
order.priceNumerator = BigInt(0);
order.priceDenominator = BigInt(0);
order.remainingAmount = BigInt(0);
}
/**
* Applies an order placement event to the auction state.
*
* @throws
* In strict mode, throws if the order ID from the event does not match the
* expected order ID based on the number of previously placed orders.
*/
private applyOrderPlacement(
order: Event<BatchExchange, "OrderPlacement">,
): void {
const userOrders = this.account(order.owner).orders;
const orderId = userOrders.length;
if (this.options.strict) {
assert(
orderId == parseInt(order.index),
`user ${order.owner} order ${order.index} added as the ${orderId}th order`,
);
}
userOrders.push({
buyToken: parseInt(order.buyToken),
sellToken: parseInt(order.sellToken),
validFrom: parseInt(order.validFrom),
validUntil: parseInt(order.validUntil),
priceNumerator: BigInt(order.priceNumerator),
priceDenominator: BigInt(order.priceDenominator),
remainingAmount: BigInt(order.priceDenominator),
});
}
/**
* Applies an emulated solution reversion event.
*/
private applySolutionReversion({
submitter,
burntFees,
}: Event<BatchExchange, "SolutionSubmission">): void {
this.updateBalance(submitter, 0, (amount) => amount - BigInt(burntFees));
}
/**
* Applies a solution submission event to the auction state.
*
* @throws
* In strict mode, throws if the account balances are left in an invalid state
* while applying a solution. This check has to be done after applying all
* trades, as a user balance can temporarily go below 0 when a solution is
* being applied.
*/
private applySolutionSubmission({
submitter,
burntFees,
}: Event<BatchExchange, "SolutionSubmission">): void {
this.updateBalance(submitter, 0, (amount) => amount + BigInt(burntFees));
if (this.options.strict) {
for (const [user, { balances }] of this.accounts.entries()) {
for (const [token, balance] of balances.entries()) {
assert(
balance >= BigInt(0),
`user ${user} token ${token} balance is negative`,
);
}
}
}
}
/**
* Applies a token listing event and adds a token to the account state.
*
* @throws
* In strict mode, throws either if the token has already been listed or if
* it was listed out of order.
*/
private applyTokenListing({
id,
token,
}: Event<BatchExchange, "TokenListing">): void {
if (this.options.strict) {
assert(
this.tokens.length === parseInt(id),
`token ${token} with ID ${id} added as token ${this.tokens.length}`,
);
}
this.tokens.push(token);
}
/**
* Applies a trade event to the auction state.
*/
private applyTrade(trade: Event<BatchExchange, "Trade">): void {
this.updateBalance(
trade.owner,
parseInt(trade.sellToken),
(amount) => amount - BigInt(trade.executedSellAmount),
);
this.updateOrderRemainingAmount(
trade.owner,
parseInt(trade.orderId),
(amount) => amount - BigInt(trade.executedSellAmount),
);
this.updateBalance(
trade.owner,
parseInt(trade.buyToken),
(amount) => amount + BigInt(trade.executedBuyAmount),
);
}
/**
* Applies a trade reversion event to the auction state.
*/
private applyTradeReversion(
trade: Event<BatchExchange, "TradeReversion">,
): void {
this.updateBalance(
trade.owner,
parseInt(trade.sellToken),
(amount) => amount + BigInt(trade.executedSellAmount),
);
this.updateOrderRemainingAmount(
trade.owner,
parseInt(trade.orderId),
(amount) => amount + BigInt(trade.executedSellAmount),
);
this.updateBalance(
trade.owner,
parseInt(trade.buyToken),
(amount) => amount - BigInt(trade.executedBuyAmount),
);
}
/**
* Applies a withdraw event to the auction state.
*
* @throws
* In strict mode, throws if the withdrawing user's balance would be overdrawn
* as a result of this event.
*/
private applyWithdraw({
user,
token,
amount: withdrawAmount,
}: Event<BatchExchange, "Withdraw">): void {
const tokenAddr = this.tokenAddr(token);
const newBalance = this.updateBalance(
user,
token,
(amount) => amount - BigInt(withdrawAmount),
);
if (this.options.strict) {
assert(
newBalance >= BigInt(0),
`overdrew user ${user} token ${token} balance`,
);
}
this.account(user).pendingWithdrawals.delete(tokenAddr);
}
/**
* Applies a withdraw request event to the auction state.
*
* @throws
* In strict mode, throws if the withdraw request is placed over top of an
* existing unapplied request.
*/
private applyWithdrawRequest({
user,
token,
batchId,
amount,
}: Event<BatchExchange, "WithdrawRequest">): void {
const tokenAddr = this.tokenAddr(token);
const batch = parseInt(batchId);
// TODO(nlordell): Add a `strict` mode check that verifies that a valid
// withdraw request can't be made over an existing one. For this, however,
// we need the current batch number as a future withdraw request that has
// not yet matured can be overwritten by another withdraw request.
this.account(user).pendingWithdrawals.set(tokenAddr, {
batchId: batch,
amount: BigInt(amount),
});
}
/**
* Normalizes a token ID or address to a token address.
*
* @throws If the token address is an invalid address or if the token id is
* not registered. This is done because event token IDs and addresses are both
* strings and can both be parsed into integers which is hard to enforce with
* just type-safety.
*/
private tokenAddr(token: TokenId | Address): Address {
if (typeof token === "string") {
assert(
token.startsWith("0x") && token.length === 42,
`invalid token address ${token}`,
);
return token;
} else {
const tokenAddr = this.tokens[token];
assert(tokenAddr !== undefined, `missing token ${token}`);
return tokenAddr;
}
}
/**
* Gets the account data for the specified user, creating an empty one if it
* does not already exist.
*/
private account(user: Address): Account {
let account = this.accounts.get(user);
if (account === undefined) {
account = {
balances: new Map(),
pendingWithdrawals: new Map(),
orders: [],
};
this.accounts.set(user, account);
}
return account;
}
/**
* Updates a user's account balance.
*/
private updateBalance(
user: Address,
token: TokenId | Address,
update: (amount: bigint) => bigint,
): bigint {
const tokenAddr = this.tokenAddr(token);
const balances = this.account(user).balances;
const newBalance = update(balances.get(tokenAddr) || BigInt(0));
balances.set(tokenAddr, newBalance);
return newBalance;
}
/**
* Retrieves an existing user order by user address and order ID.
*
* @throws If the order does not exist.
*/
private order(user: Address, orderId: number): Order {
const order = this.account(user).orders[orderId];
assert(
order,
`attempted to retrieve missing order ${orderId} for user ${user}`,
);
return order;
}
/**
* Updates a user's order's remaining amount. For unlimited orders, the
* remaining amount remains unchanged.
*
* @throws If the order does not exist.
*/
private updateOrderRemainingAmount(
user: Address,
orderId: number,
update: (amount: bigint) => bigint,
): void {
const order = this.order(user, orderId);
if (
order.priceNumerator !== UNLIMITED_ORDER_AMOUNT &&
order.priceDenominator !== UNLIMITED_ORDER_AMOUNT
) {
order.remainingAmount = update(order.remainingAmount);
if (this.options.strict) {
assert(
order.remainingAmount >= BigInt(0),
`user ${user} order ${orderId} remaining amount is negative`,
);
}
}
}
}
/**
* An error that is thrown on a unhandled contract event.
*/
export class UnhandledEventError extends Error {
constructor(public readonly ev: ContractEventLog<unknown>) {
super(`unhandled ${ev.event} event`);
}
}
/**
* Asserts that the specified array of events are in order by ensuring that both
* block numbers and log indices are monotonically increasing and that they all
* come after the specified block.
*
* @param block - The block number that all events must come after
* @param events - The array of events to check order and batch
*/
function assertEventsAreAfterBlockAndOrdered(
block: number,
events: EventLog[],
): void {
let blockNumber = block;
let logIndex = +Infinity;
for (const ev of events) {
assert(
blockNumber < ev.blockNumber ||
(blockNumber === ev.blockNumber && logIndex < ev.logIndex),
`event ${ev.event} from block ${ev.blockNumber} index ${ev.logIndex} out of order`,
);
blockNumber = ev.blockNumber;
logIndex = ev.logIndex;
}
}