@fleupold/dex-contracts
Version:
Contracts for dFusion multi-token batch auction exchange
401 lines (400 loc) • 15.7 kB
JavaScript
import assert from "assert";
/**
* Amount used to signal that an order is an unlimited order.
*/
const UNLIMITED_ORDER_AMOUNT = BigInt(2 ** 128) - BigInt(1);
/**
* Manage the exchange's auction state by incrementally applying events.
*/
export class AuctionState {
constructor(options) {
this.options = options;
this.lastBlock = -1;
this.tokens = [];
this.accounts = new Map();
}
/**
* Creates a copy of the auction state that can apply events independently
* without modifying the original state.
*/
copy() {
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.
*/
toJSON() {
function map2obj(map, convert) {
const result = {};
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.
*/
getOrders(batch) {
let orders = [];
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.
*/
getEffectiveBalance(batch, user, token) {
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.
*/
get nextBlock() {
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.
*/
applyEvents(events) {
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.
*/
applyDeposit({ user, token, amount: depositAmount, }) {
this.updateBalance(user, token, (amount) => amount + BigInt(depositAmount));
}
/**
* Applies an order cancellation event to the auction state.
*/
applyOrderCancellation({ owner, id, }) {
// 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.
*/
applyOrderDeletion({ owner, id, }) {
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.
*/
applyOrderPlacement(order) {
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.
*/
applySolutionReversion({ submitter, burntFees, }) {
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.
*/
applySolutionSubmission({ submitter, burntFees, }) {
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.
*/
applyTokenListing({ id, token, }) {
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.
*/
applyTrade(trade) {
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.
*/
applyTradeReversion(trade) {
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.
*/
applyWithdraw({ user, token, amount: withdrawAmount, }) {
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.
*/
applyWithdrawRequest({ user, token, batchId, amount, }) {
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.
*/
tokenAddr(token) {
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.
*/
account(user) {
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.
*/
updateBalance(user, token, update) {
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.
*/
order(user, orderId) {
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.
*/
updateOrderRemainingAmount(user, orderId, update) {
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(ev) {
super(`unhandled ${ev.event} event`);
this.ev = ev;
}
}
/**
* 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, events) {
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;
}
}