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
- Proxy
- 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
conditionalOrderfield points to the "template" SwapOrder that will be placed anew under the right conditions. In turn, the newly placed order will have itsoriginalOrderpointing back.
NOTE 3: When placing an order that has
conditionalOrderset, the referenced conditional order must meet certain criteria: (1) it must not itself have aconditionalOrderand (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%.