Files
contract/doc/review.md
dexorder 25def69c66 dexorder
2024-10-17 02:42:28 -04:00

29 KiB

Introduction

Audience

This document targets technical users who are familiar with Solidity, the EVM, the ERC20 protocol, the Uniswap V3 API, and common design patterns in blockchain financial protocols, such as oracles.

Purpose of This Document

After reviewing this document the reader should have a good grasp of the operating principles of Dexorder, and feel able to directly utilize the public Dexorder contract functions. However, Dexorder is intended as a system primarily used by end-users via the Dexorder Web3 UX.

Instead, the purpose of this document is to foster understanding of the system so that the community and auditors may evaluate the Dexorder contracts for safety, trust, and correct implementation.

Dexorder's Construction

Dexorder comprises several cooperating technologies that implement complex order behaviors not otherwise supported by Uniswap. Dexorders are also executed in a context that is at least partially externalized from Uniswap.

The technologies supporting these advanced order types include the Dexorder:

  • EVM contracts (the focus of this document)
  • Backend
  • Web3 user interface

The two basic order types supported by Dexorder include limit orders and market orders. Dexorder powerfully augments these two basic order types by (optionally) combining them with algorithms that determine parameters of those orders including the time, price and quantity at which an order may execute.

The Dexorder EVM contracts

Dexorder's contracts implement several logical components of the overall Dexorder system, as follows:

  • Vaults
    • Proxy
      • Kill system
      • Upgrade system
    • Vault Factory
    • Vault
    • Orders
    • Tranches
  • FeeManager
  • Router

Proxy

By the fundamental design of the EVM, contracts cannot be changed once deployed. Instead of upgrades, contracts may delegate their implementation via proxies. By changing the address to which a proxy delegates, deployers can effectively change (upgrade) contracts. Dexorder employs this well established proxy upgrade pattern. Thus, Dexorder Vault contract addresses are in fact the address of a proxy. That proxy forwards most contract calls to an implementation contract.

The Dexorder vault proxy implements certain core functions directly. Any function it does not directly implement is forwarded to the implementation contract. Therefore the functionality of those calls it directly implements can never be changed. Those functions are also not part of the kill system described in the next section.

The directly implemented functions are the ones used for moving funds in and out of vaults. Thus, users may have full confidence that proxy implementation upgrades cannot interfere with fund movement.

Kill system

An individual Vault may be killed by the owner of that vault. Vaults may also be globally killed by Dexorder. When a vault is killed, only deposit and withdraw operations continue to function for that vault. These kill switches are implemented directly in the proxy. Thus, they cannot be disabled or upgraded.

The Kill functionality may be useful in the event of some unforeseen attack on the overall system, or when an individual user is attacked, or suspects compromised keys.

Killing takes immediate effect and is irreversible.

Upgrade system

Upgrades to the vault implementation may be proposed by Dexorder via the VaultFactory (see below). When proposed, the vault implementation upgrade goes into a pending state for a pre-determined period. This pre-determined pendency period is fixed within the non-upgradable VaultFactory contract at the time it is initially deployed. Therefore, the pendency period cannot be changed, even by Dexorder.

During the pendency period of an upgrade, users have the opportunity to review the proposed upgrade. At any time, a user may choose to kill their individual vault, remain on the version of the vault implementation they are currently on, or upgrade their vault's implementation (only if an upgrade is currently available and past its pendency period).

In this way, users may be confident that they will be able to review upgrades before any possibility that those upgrades will take effect. Irrespective of the pendency system, upgrades anyway never automatically take effect. Instead, individual Vaults must be explicitly upgraded to the current vault implementation. This upgrade operation may only be done by the vault owner (the user), and is on a strictly opt-in basis.

There is no other global upgrade mechanism. Thus, vault upgrades (and therefore any changes to the vault implementation) are always controlled by users. If a user does not explicitly upgrade their vault, their vault will continue to function using whatever version of the system is currently active on that vault.

This system ensures that users always feel secure relative to Dexorder's upgrade proposals. If a user does not trust an upgrade, they can simply refuse that upgrade.

Note: Dexorder cannot guarantee that its Web3 UX or backend will indefinitely support all prior versions of vaults.

Vault Factory

The factory implements APIs used by the Dexorder Web3 UX when onboarding new users. The UX uses the VaultFactory to deploy individual user vaults. The VaultFactory API may be used by anyone.

The VaultFactory also adjudicates vault implementation upgrade proposals, storing the proposed vault implementation upgrade and the proposed block time after which the new implementation becomes valid.

As mentioned above, the proposed upgrade block time is always a fixed time interval after the proposal is made, an interval fixed when the VaultFactory itself is deployed.

The VaultFactory also contains a global "kill switch" for all vaults, as described above in "Kill system".

Vault

Each user Vault is deployed by the Dexorder VaultFactory and is in fact a Proxy, delegating the functionality for most of its contract calls to an implementation contract. User funds are in the custody of their individual Vault.

The implementation contract address for a given Vault is set at the time of the Vault's creation according to the then current implementation contract address set in the VaultFactory. Users may optionally upgrade their Vault's implementation contract address to the one currently set in the Vault Factory, after the pendency period for the new implementation address is fulfilled.

If a user does not upgrade, their Vault's Dexorder functionality will remain intact and they will not benefit from bug fixes or new features. Future versions of the Dexorder UX and backend may or may not continue to support older Vault implementations, but that Vault's API will continue to function. Thus, even in the worst case, users may directly call the Vault API.

Note: Currently, user Vaults are completely self contained except for their reliance on the shared implementation contract. There are no on-chain central data stores or external functions that create dependencies on user Vault versions, and therefore there is no reciprocal version dependence within a Vault on any external components. A Vault's functionality can thus continue indefinitely, even if the overall Dexorder system (backend, UX, etc) is not functioning (so long as the VaultFactory central Kill function has not been invoked--see above). However, future versions of the Vault implementation logic may have end up having broader dependencies. Hypothetically, such future dependencies could imply changes to the optionality of upgrades. Irrespective of this possibility, users will always have the option to opt out of an upgrade, losing functionality if that upgrade is mandated by related contracts. Again, users would still maintain the ability to independently kill their individual vault and withdraw funds. In this hypothetical scenario, which is not currently the case, such users would only lose the functionality of their Dexorders.

Orders

Individual orders are stored in a user's vault. The code for executing orders (in other words, the orders' functionality) is defined by the vault implementation contract currently associated with that user's vault.

Supported order behaviors are described in detail below, in the Order Types section.

Orders have two separate representations in blockchain storage, which will be discussed in more detail in a subsequent section of this document:

  • The "as entered" order: SwapOrder
  • The "as executing" order status: SwapOrderStatus

The SwapOrder stores the user's intention for the order, while the SwapOrderStatus stores the necessary state variables that allow the execution API to correctly execute the order.

Tranches

All Dexorders are executed by dividing them into one or more Tranches. A Tranche consists of a set of parameters that define the order's behavior, because they are used to calculate parameters of physical requests sent to a liquidity pool for the purpose of executing a swap.

Like orders, Tranches have two separate representations in blockchain storage, and will be discussed in more detail subsequently:

  • The "as entered" Tranche: Tranche
  • The "as executing" Tranche status: TrancheStatus

Each Dexorder storage structure, SwapOrder or SwapOrderStatus, contains an array of Tranche or TrancheStatus. The simplest possible Dexorder is a market order that has one Tranche, with no rate limiting, and an immediate activation time. For such an order, the Dexorder system will typically attempt to match (swap) the order on the DeFi pool in a single transaction.

Orders may be split into more than one Tranche. Each Tranche executes according to its own time, price, and amount attributes. It is possible to define start and end times for two Tranches such that they overlap, in which case the execution ordering of co-activated Tranches is not deterministic. In no event will the system execute swaps that cause the overarching SwapOrder parameters to be violated. For example, a SwapOrder will never swap an amount in excess of what's defined in the SwapOrder, even if the amounts defined in two tranches exceed the SwapOrder amount.

Tranches are the mechanisms that allows the Dexorder execution system to split up an order's executions into separate swaps against the associated DeFi pool. Complex order behavior is achieved by encoding the necessary features into an order's Tranches.

Example: Time Weighted Average Price (TWAP)

A market order for 1 WETH is split up into 10 Tranches for 0.1 WETH each, where the start time of each Tranche is 10 minutes after the start time of the prior Tranche.

TWAP functionality is usually better provided by the Rate Limiting mechanism on a single tranche rather than by constructing many tranches. This is only here as an illustrative example of Tranches. If an execution should happen near an exact time, creating tranches that activate at absolute timestamps should be used.

Example: Chase to a Level

An order could want to increase its swap limit price over some time period, to get more urgent about crossing the market as time goes on, but then want to stop increasing the price at a certain maximum amount, not chasing the market too far. This can be encoded using two tranches: one tranche for the full amount with a sloped limit line that expires when it reaches the plateau price, plus a second tranche activating when the first expires, also for the full amount but with a flat limit price.

FeeManager

The Fee Manager is a separate contract used to encode (and report) Dexorder's currently active fee schedule, as well as manage changes to those fees. Orders copy the active fee settings out of the Fee Manager at the time the Order is stored in a user's Vault (i.e., when the order is placed). This locks in the fees a user will pay to execute an existing order, at the time it is entered into the Vault.

The FeeManager is referenced by each user's Vault implementation, and is therefore an upgradable component of the Vault. It is the authoritative source and mediator for what fees Dexorder charges and can possibly charge.

VaultManager implements three fees:

  • a per-order "order fee", payable upon order placement in native token
  • a per-anticipated-execution "gas fee", payable upon order placement in native token
  • a "fill fee", paid as a fraction of the proceeds token received from each executed DeFi swap

FeeManager enforces maximum limits on these fees as well as a delay before any new fees take effect. These delays are called notice periods and they afford clients of Dexorder time to review new fee schedules before they take effect. The fee schedule for any given order is locked-in at order creation time, and all future fills for that order will be charged the fill fee that was in effect when the order was created.

There are two notice periods: a short notice period for changing the fee schedule within set limits, plus a longer notice period for changing the fee limits themselves. This allows fees to be adjusted quickly as the market price of native token changes, while preventing a malicious fee manager from suddenly charging exorbitant fees. The up-front fees in native coin must be sent along with the order placement transaction, which means any wallet user will clearly see the fee amounts in their wallet software. The fill fee has less up-front transparency, but it is also hard-limited to never be more than 1.275%, by virtue of being represented as a uint8 value of half basis points (divided by 20_000).

The fee administrator at Dexorder may propose changes to the fees at any time, but the proposed fees do not take effect until a sufficient "notice period" has elapsed. There are two notice periods: a short notice period to make changes to the fee schedule itself (within the fee limits,) but a longer notice period to change the maximum fee limits allowed.

As mentioned above, any orders which were created with a promised fill fee will remember that fee and apply it to all fills for that order, even if Dexorder changes the fee schedule while the order is open but not yet executed.

Router

The Router is implemented as an external contract in order to reduce the Vault implementation contract's size. It provides internal APIs used by Dexorder to route immediately executable swaps to DeFi pools, and to query those pools for current prices.

The Dexorder Backend

Overview of the Dexorder Execution Model

The EVM processes transactions by request only. Therefore, Dexorders stored in user Vaults must be explicitly executed via the Vault's execution API. Dexorder's execution model is simple:

  • Dexorders are "entered" into a user's Vault via that Vault's order placement API
  • Dexorders are "executed" by calling a user's Vault's execution API

Anyone may request the execution of a given Dexorder, even if the order is in a Vault they do not own. Of course, that execution will only succeed if the parameters of the order, as evaluated against the instant state of the world, would result in a valid execution.

Although anyone may request execution of a Dexorder, the Dexorder system also implements an automated execution request system: The "backend." This system is described in more detail below.

Presumably, no one would waste resources by requesting the execution of non-executable orders, but doing so is in any case of no negative impact on Dexorder, except insofar as reverted execution transactions clog up the blockchain itself.

The Dexorder execution model is optimistic and opportunistic. Dexorders enforce that they will only execute in accordance with their definition, but not that they will execute in some specific way relative to either all their Tranches, all other orders in a user's Vault, nor all other Dexorders across all other users' Vaults. So long as an order can execute, if a transaction requesting its execution is submitted to the blockchain, it will execute.

Example

It's possible to define a Dexorder with Tranches whose start and end times overlap, and which specify a total of more than 100% of the overall SwapOrder's asset amount. Which of these two overlapping Tranches would execute (or even, whether both would execute) during a given blockchain transaction depends only on which Tranches are requested to execute, and in what order.

Imagine two overlapping Tranches that together request 200% of an order's base amount: Tranche 1 is for 100% of the order's base amount rate limited over some period, and Tranche 2 is also for 100% of the order's base amount over some overlapping period.

Despite this, no execution would ever exceed the base order's amount because the execution logic limits the amounts swapped on liquidity pools according to the SwapOrder's limits. Either or both Tranches may execute during the overlapping period in which they are both active, depending only on whether a transaction requesting that Tranche to execute is submitted.

What does the backend do?

Dexorder's backed is a system that runs outside the blockchain. It consists of multiple cooperating on and off-chain components that service all extant Dexorder vaults and the orders stored within them. It monitors the blockchain in order to call users' Vaults' execution APIs automatically when orders become executable.

As previously mentioned, anyone may call those APIs. Dexorder's backend in fact uses the very same API that anyone else can call to perform that same service. There is no special access API utilized by the backend for executing Dexorders.

If there is a problem with Dexorder's backend, users (or third parties) may nevertheless execute Dexorders by invoking the on-chain execution API directly. The only difference will be who pays the gas.

Further details of Dexorder's backend are beyond the scope of this document.

Security and Economics of the Backend

The backend has no security implications for Dexorder users' Vaults, funds, or orders. Relative to on-chain operations involving those elements, the backend operates in the same security context as any third-party would.

However, the Dexorder backend does have access to Dexorder's own assets, such as assets used to pay for gas costs of executing users' orders. If Dexorder does not have funds to pay for the gas to execute orders, those orders cannot be executed. If a user (or third party) chooses to execute their own orders, those gas costs would not be paid by Dexorder.

In a worst case scenario, anyone can execute Dexorders by calling the execution API.

User funds are only ever stored in the user's vault, and transfer of those funds can never be disabled. Fund transfers do not depend on the backend, nor on Dexorder's gas resources.

The Dexorder Web3 User Interface

The Dexorder user interface is beyond the scope of this document. Suffice it to say that there exist no non-public APIs for user Vaults. The Dexorder interface is just one possible interface to the Dexorder system, and users are free to build their own interfaces, or to use the public APIs directly, and anyone can build an alternative interface to Dexorder Vaults.

In fact, as we will see in the following sections, the Dexorder system allows users to compose complex orders that may not be currently composable via the Dexorder user interface.

Storage Structs Detail

As described above, each Dexorder is in fact a SwapOrder within which one or more Tranches are defined. During execution of that SwapOrder, its status is tracked in a corresponding SwapOrderStatus, within which each Tranche has a corresponding TrancheStatus.

SwapOrder & SwapOrder Status

struct SwapOrder {
    address tokenIn;
    address tokenOut;
    Route route;
    uint256 amount;
    uint256 minFillAmount;
    bool amountIsInput;
    bool outputDirectlyToOwner;
    uint64 conditionalOrder;
    Tranche[] tranches;
}

struct SwapOrderStatus {
    SwapOrder order;
    uint8 fillFeeHalfBps;
    bool canceled;
    uint32 startTime;
    uint64 ocoGroup;
    uint64 originalOrder;
    uint256 startPrice;
    uint256 filled;
    TrancheStatus[] trancheStatus;
}

Most of the SwapOrder elements are self explanatory. minFillAmount controls the minimum amount that will ever be requested in a DeFi swap; therefore, it also defines the amount below which any remaining quantity in the SwapOrder or in any given Tranche of the SwapOrder is treated as 0 (i.e., the Tranche is treated as complete). conditionalOrder and tranches are discussed below.

A user's Vault contains an array of SwapOrderStatus structs. Each SwapOrderStatus contains the state variables used by the Dexorder system to execute the contained SwapOrder. When a Vault receives a SwapOrder for placement, it appends a new SwapOrderStatus onto this array, and copies the SwapOrder into the order field. Indexes into this array identify specific Dexorders, and are used throughout the Dexorder execution system for this purpose.

The conditionalOrder field is an example of such an index: It references a new order to place when conditions are met during execution of the containing SwapOrder. When there is no conditional order (no dependent order), the field is set to NO_CONDITIONAL_ORDER.

conditionalOrder is usually an absolute index into the Vault's SwapOrderStatus array. However, when passing in multiple orders to a Vault API call, its value may have the CONDITIONAL_ORDER_IN_CURRENT_GROUP bit set. In that case, the index (after masking the bit) is interpreted as relative to the beginning of the set of orders passed into the Vault API call. For example, when placing a new set of orders, one of which is the conditionalOrder of another order, the Web3 UX sets this bit in order to refer to the dependent order being placed in the same batch.

NOTE 1: A conditional order must be placed before any parent order can reference it. This is true whether placing orders separately or within a batch: the conditional order index must be lower than the index of any order referencing it.

NOTE 2: In these docs, the "conditional order" (or "dependent order") is the order "pointed to" by the "triggering" or "parent" order. Thus, the conditionalOrder field points to the "template" SwapOrder that will be placed anew under the right conditions. In turn, the newly placed order will have its originalOrder pointing back.

NOTE 3: When placing an order that has conditionalOrder set, the referenced conditional order must meet certain criteria: (1) it must not itself have a conditionalOrder and (2) it must have an 0 amount. This avoids the possibility of order placement loops, or execution of a conditional order. If the conditional order is ever activated (placed anew), the amount will be set to a non-zero value in the newly placed order.

startTime stores the blocktime of the transaction that placed the SwapOrder. The value of this field is relevant to resolving other times within the SwapOrder and its Tranches when those dependent times are configured to be relative to startTime.

ocoGroup is a calculated index into an internal table that lists all of the SwapOrder indexes that are part of that ocoGroup. All of those orders will be canceled as a group if the "one cancels the other" condition associated with the the group occurs. The Vault's table of "ocoGroups" is managed by the Vault logic, and is populated at the time of Dexorders' placement into the Vault.

When an order has a conditionalOrder set, and the condition is met, a new order based on the referenced order is placed. The newly placed order will have an originalOrder tracking that originally referenced order. Otherwise originalOrder refers to the instant order's index.

startPrice is set to the router's protectedPrice(), which is not the pool's current price but a manipulation resistant price. For UniswapV3 this is a short-term TWAP. Not all orders require the startPrice, so to save gas, the value is not always set.

All prices are specified in terms of the input token as the base and the output token as the quote. That is, all swaps are priced as if they are sells. This means that the minPrice line is always the standard limit line, and the max line is used rarely, for example to wait for a breakout in the order's direction before executing.

Tranche & TrancheStatus

struct Tranche {
    uint16 fraction;
    bool   startTimeIsRelative;
    bool   endTimeIsRelative;
    bool   minIsBarrier;
    bool   maxIsBarrier;
    bool   marketOrder;
    bool   minIsRatio;
    bool   maxIsRatio;
    bool   _reserved7;
    uint16 rateLimitFraction;
    uint24 rateLimitPeriod;

    uint32 startTime;
    uint32 endTime;

    Line   minLine;
    Line   maxLine;
}

struct TrancheStatus {
    uint256 filled;
    uint32  activationTime;
    uint32  startTime;
    uint32  endTime;
}

Like SwapOrder and SwapOrderStatus, Tranche contains the definition of a Tranche while the corresponding TrancheStatus stores the state variables necessary to properly trade the Tranche. Tranches are stored in an array within the SwapOrder, while TrancheStatuses are in an array in the SwapOrderStatus.

If more than one Tranche is eligible for execution, whether only one or both execute, and in what order, is not defined. However, under no circumstance will the execution of one or more Tranches result in execution of more than the SwapOrder's amount.

fraction stores an integer representing the maximum amount of the containing SwapOrder that this Tranche can fill. The tranche's size is order.amount * (tranche.fraction / MAX_FRACTION).

startTimeIsRelative and endTimeIsRelative indicate whether the startTime and endTime fields are values relative to the startTime stored in the SwapOrderStatus. If true, the values of the startTime and endTime fields in the TrancheStatus will be set by adding the corresponding Tranche's time to the SwapOrderStatus' startTime

marketOrder is true when the Tranche should be executable at any price. If marketOrder is true then the minLine.intercept is treated as a slippage parameter. Market orders use the Router's protectedPrice() adjusted by this slippage float to determine their instantaneous limit price. If slippage is set to 0, then slippage management is disabled and a true market order will be placed.

minIsRatio and maxIsRatio are true when the minLine and maxLine should be interpreted as ratios. More on this below.

The Vault code that executes Tranches intrinsically supports a rate limiting feature, parameterized by rateLimitFraction and rateLimitPeriod. The rateLimitFraction parameter, like the fraction parameter, expresses a ratio vs MAX_FRACTION that is the largest portion of the total Tranche amount that should be filled per rateLimitPeriod. The amount implied by the rateLimitFraction is the order.amount * (tranche.fraction / MAX_FRACTION) * (tranche.rateLimitFraction / MAX_FRACTION).

If executed, a rate-limited Tranche will not be eligible for further executions until at least a pro-rata portion of the rateLimitPeriod elapses, based on the executed amount. For example, if 82% of the amount implied by the rateLimitFraction is executed, then 82% of the rateLimitPeriod must elapse before the Tranche is once again eligible for execution. However, when it becomes eligible for execution once again, the full amount implied by rateLimitFraction may be executed. Thus, the executions of a Tranche may be more frequent than implied by rateLimitPeriod if each execution is in fact smaller than requested from the pool. activationTime tracks the next time at which a Tranche is eligible for execution, if rate limiting is in effect.

minLine and maxLine define lines in "mx + b" (slope + y-intercept) form, unless the corresponding "ratio" boolean is true. In that case, the line's slope is maintained, but its intercept is calculated such that the line passes through the current price adjusted by the ratio specified in the "intercept" part of the minLine or maxLine. If both slope and intercept are zero, then the line is disabled.

Whether in ratio or normal mode, the minLine and maxLine parameters are used to calculate min and max prices. A Tranche is only eligible for execution if the pool's instantaneous price is between the min and max prices.

Finally, filled tacks the amount for which this Tranche has been filled so far. In no case will a Tranche fill more than its fraction of the SwapOrder's amount.

Supported Use Cases & Order Types

Algorithm: OCO (One Cancels Other)

Orders may be entered such that execution of one order, in whole or in part, cancels all other orders in the OCO group.

Example Use Case

  • A trader wishes to avoid buying asset A if they successfully buy asset B instead.

Algorithm: Line following limit price

The price of an order may be set according to a line determined by two points on a chart. This allows, for example, a limit order to increase or decrease the limit price as time goes on.

Example Use Case

  • A trader is willing to pay more for an asset as time goes on, if the market is trending up.

Algorithm: Rate limit / Time Slice

The quantity matched at a single point in time may be limited such that the total quantity of an order matches across a given time frame.

Example Use Case

  • A trader wants to minimize market impact, so they only want to swap 5% of their total target qty every 15 minutes until they've acquired 100%.