UNPKG

@fleupold/dex-contracts

Version:

Contracts for dFusion multi-token batch auction exchange

401 lines (400 loc) 15.7 kB
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; } }