econia-labs / econia

Hyper-parallelized on-chain order book for the Aptos blockchain
https://econia.dev
Other
141 stars 53 forks source link

Add fee model #7

Closed alnoki closed 2 years ago

alnoki commented 2 years ago

As a proof-of-concept prototype, Econia v1 implemented spot trading without fees, to prove out the basic functionality required for an order book. For future releases, however, a fee model is proposed to provide an incentive 1) for market makers to place limit orders, and 2) for protocols to build on top of Econia.

Market maker incentives

A straightforward solution is for takers (market orders) to directly subsidize makers (limit orders) in the quote currency. For instance, a .05% taker fee and a -0.05% maker fee: Maker A places a bid for 100.00 PRO (protocol token) at a price of 10.0 USDC per PRO, which locks up not 100.00 * 10.0 = 1000.0 USDC, but instead only 99.95% * 1000.00 = 999.5 USDC. Taker B then submits a market order to sell 100.00 PRO, which matches against Maker A's bid at a price of 10.0 USDC per PRO. Here, the fill size is evaluated to 1000.00 USDC, the .05% fee is deducted, and as such, 999.5 USDC is routed from Maker A to Taker B, and 100.00 PRO is routed from Taker B to Maker A.

In the case of inverted sides: Maker C places an ask for 100.00 PRO, also at a price of 10.0 USDC per PRO, which locks up 100.00 PRO. Taker D then submits a market order to buy 100.00 PRO regardless of price (or, alternatively, to make a purchase totaling 1000.50 USDC), and since Maker C's order has the minimum ask price on the book, the order is matched. Here, 100.00 PRO is routed from Maker C to Taker D, and 1000.5 USDC is routed from Taker D to Maker C.

Protocol incentives

For protocols to collect their own fees without inhibiting market maker incentives, a proposed solution is for protocols to collect fees from takers only, also denominated in the quote currency, at an optional percentage they set themselves. For instance, in the case of Taker B's order above with an additional 0.02% protocol fee, after 999.5 USDC is routed to Taker B, an additional .2 USDC is routed to the protocol which placed the order. In the case of a 0.03% protocol fee for Taker D above, after 1000.05 USDC is routed from Taker D to Maker C, and additional 0.3 USDC is routed to the protocol which placed the order.

Here, protocols would likely initialize a fee collection container of the form:

struct ProtocolFeeStore<phantom B, Q, phantom E> has key {
    quote_coin: aptos_framework::coin::Coin<Q>
}

When generating a transaction that a user signs, fee-collecting protocols would then submit to the requisite functions (place_limit_order(), etc) the optional protocol fee percentage they wish to collect, and the address where their ProtocolFeeStore resides. The requisite functions will then verify that a ProtocolFeeStore exists at the given address, then forward along the address to the matching engine so that funds can be routed their accordingly. To prevent accumulated integer truncation that would reduce protocol-specified fees, it is suggested that calculation on protocol fees be performed after the net fill amount is determined.

Dynamism

Proposals are welcome for dynamic fee model strategies

alnoki commented 2 years ago

Fixed fee tiers

Per the suggestion of a third-party protocol developer: if Econia allows dynamic protocol fees, then users will just go to the protocol/front end that provides the lowest fee, and hence it is in the interest of integrators to have a fixed fee tier but with a portion that is shared with integrators. In this case, protocols would then have direct incentives to route trades through Econia, and if by doing so they increase trading volume to sufficiently high levels, then it will be possible to lower overall fees in a way that preserves incentives for both market makers and integrators, while offering a better price to takers (e.g. retail traders).

Thus in the above examples, there would be a flat 0.05% fee charged to takers, with, for instance, 0.03% going to market makers and 0.02% going to integrators. In this case, integrators would likely pass a third-party address to order placement functions, describing the address under which they store a ProtocolFeeStore for the given fee CoinType:


/// Container for third-party protocol fees
struct ProtocolFeeStore<CoinType> has key {
    /// Indexed by market to prevent transaction collisions across markets
    third_party_fees: open_table::OpenTable<MarketInfo, Coin<CoinType>>
}

Here, for each CoinType, third-parties will have a table of collected fees indexed by econia::registry::MarketInfo

alnoki commented 2 years ago

Asset-agnostic considerations

Per #13 , Econia now supports asset-agnosticism, allowing generic type parameters for both base and quote assets. Per this implementation, a general asset transfer custodian is required to approve deposits and withdrawals of generic assets. Hence, should a market contain a generic quote asset, a custodian would be required to process withdrawals from a ProtocolFeeStore. There is no guarantee, however, that such a custodian would approve such withdrawals for the withdrawing party, so it may be necessary to stipulate that the quote asset for a market is indeed a coin. Here, generic assets could still be traded as the base, but only against quote coin types.

Econia fee share

Per #14 , a proposed implementation involves a 0.05% fee charged to takers, with 0.03% going to makers and the remaining 0.02% administered according to an "activated fee store model": integrators by default collect 0.01% commission, and can collect an additional 0.01%, for a total of 0.02%, if they "activate" their fee store by paying into the Econia treasury. If they do not activate the fee store, the unclaimed 0.01% goes into an EconiaFeeStore. This model correlates token demand with protocol volume, an approach that helps deter excessive bogus transactions on the network.

Rollout considerations

Without launching the Econia coin at the same time as mainnet, a gradual transition is still enabled by a utility token paradigm which simply uses the Aptos coin, henceforth referred to as APT, for market registration, custodian registration, and fee store activation. Here, an on-chain variable will specify the CoinType of the utility coin required for such purposes, and will simply be set to APT at mainnet launch. Similar on-chain variables will specify the amount, in indivisible subunits, required for specific tasks, and can be updated as required to maintain a constant price, for example, of $100 USD per market registration. This will prevent bogus registrations on the network and provide the Econia treasury with funds for future development, initiatives, etc.

Data structures

In accordance with the coin-only quote asset paradigm described above, a straightforward implementation involves registering an EconiaFeeStore<CoinType> entry whenever registering a general <AssetType, CoinType> market, at the same address as the order book host:

/// Container for integrator protocol fees
struct EconiaFeeStore<QuoteCoinType> has key {
    /// Map from market ID to fees from market
    map: open_table::OpenTable<u64, Coin<QuoteCoinType>>
}

Here, if the EconiaFeeStore does not exist for the given coin type at the host address, it is initialized via move_to when the host signs the registration transaction, thus supplying the correspondent type parameter. Then a corresponding table entry is initialized with an empty coin, which is incremented with Econia fees during trading. Only the Econia address will have the authority to withdraw funds from the EconiaFeeStore.

Coin-only quote assets

Again, by enforcing that quote assets have to be coins, there is no risk that a generic asset transfer custodian will prevent withdrawal of funds, and there is no inventory risk for holding exotic assets. The easiest way to enforce this paradigm is to simply disable non-coin quote assets at the registry level, such that the functionality can be re-enabled later if there exists a way to avoid the aforementioned risks/difficulties.

In this regard, one potential implementation involves a generic asset transfer custodian capability with a revokable permission (e.g. is_valid field), but then there is the question of who revokes the permission.

Tiered fee stores

Rather than a simple activated/deactivated state, there exists a potential for tiered activation status:

/// Container for integrator protocol fees
struct IntegratorFeeStore<CoinType> has store {
    /// Activation tier, incremented by paying Econia tokens to treasury
    tier: u8,
    /// Collected fees
    coins: Coin<CoinType>
}

/// All `IntregratorFeeStore`s for given `CoinType`
struct IntegratorFeeStores<CoinType> has key {
    /// Map from market ID to collected fees
    map: open_table::OpenTable<u64, Coin<CoinType>>
}

Here, utility coins (APT at mainnet launch, later ECON) would be required to activate different tiers, where, for example, the following fee splits are realized between integrators and the Econia treasury:

Tier Integrator % Econia %
1 0.010 0.010
2 0.012 0.008
3 0.014 0.006
4 0.016 0.004
5 0.018 0.002
6 0.020 0.000

In this case, different utility coin payments would be required to activate different tiers, with volumetric logic potentially determining the cost per tier: activating to tier 6 on a $1M USD daily volume market should cost more than activating to tier 6 on a $1k USD market, but this kind of logic would likely require integrations with an off-chain oracle. So instead the most straightforward solution may just involve paying $100 USD equivalent (first in APT then in ECON) for tier 2, $1000 USD for tier 3, $10000 USD for tier 4, etc. Tier 1 would be enabled by default

alnoki commented 2 years ago

Fee withdrawal considerations

Since integrator fee store collection involves state overlap with the matching engine, it is advised that a fee be imposed on withdrawals, to prevent excessive withdrawals and thus minimize transaction collisions. Here, the cost to withdrawal from the integrator fee could additionally be tiered, such that tier 1 pays 100 indivisible subunits, tier 2 pays 80 indivisible subunits, etc., with the highest tier paying a marginal but nonzero amount so that they are incentivized to reduce transaction collisions.

Data structures

To prevent transaction collisions with the existing econia::registry::Registry, it is advised that the relevant parameters be stored in a separate global storage resource:

/// Integrator fee store tier parameters for a given tier
struct IntegratorFeeStoreTierParameters has store {
    /// Nominal amount divisor for fee calculation. For example, if
    /// the transaction amount is 1000000 units and the fee share
    /// divisor is 5000, the integrator receives 5000^(-1), or
    /// 0.02% of the transaction amount, 200 units. Instituted as a
    /// divisor for optimized calculations.
    fee_share_divisor: u64,
    /// Cumulative cost, in utility coin units, to activate to the
    /// current tier. For instance if an integrator has already
    /// activated to tier 3, which has a tier activation fee of 1000
    /// units, and tier 4 has a tier activation fee of 10000 units, 
    /// the integrator only has to pay 9000 units to activate to
    /// tier 4.
    tier_activation_fee: u64,
    /// Cost, in utility coin units, to withdraw from an integrator
    /// fee store. Shall never be nonzero, since a disincentive is
    /// required to prevent overly-frequent withdraws and thus
    /// transaction collisions with the matching engine.
    withdrawal_fee: u64,
}

/// Utility coin parameters for assorted operations
struct UtilityCoinParameters has key {
    /// Phantom `CoinType` for the
    /// `aptos_framework::coin::Coin<CoinType>` used for utility
    /// functions.
    utility_coin_type_info: type_info::TypeInfo,
    /// `Coin.value` required to register a market
    market_registration_fee: u64,
    /// `Coin.value` required to register as a custodian
    custodian_registration_fee: u64,
    /// 0-indexed list from tier number to corresponding parameters. 
    integrator_fee_store_tiers: vector<IntegratorFeeStoreTierParameters>
}
alnoki commented 2 years ago

0-indexing considerations

With fee store tier parameters in a 0-indexed vector, the element index thus corresponds to the given tier, with "tier 0" corresponding to the base integrator fee store parameters. Per the above table, with the addition of withdrawal fees, an example tier schema is as follows:

Tier Integrator % Econia % Withdrawal fee Activation Fee
Base 0.010 0.010 100 1000000
1 0.012 0.008 80 2000000
2 0.014 0.006 60 5000000
... ... ... ... ...

Fee-inclusive data structures

With fee shares additionally tabulated in a global resource, the following data structures thus constitute a comprehensive paradigm:


/// Container for integrator protocol fees
struct EconiaFeeStore<QuoteCoinType> has key {
    /// Map from market ID to fees from market
    map: open_table::OpenTable<u64, Coin<QuoteCoinType>>
}

/// Container for integrator protocol fees
struct IntegratorFeeStore<CoinType> has store {
    /// Activation tier, incremented by paying Econia tokens to treasury
    tier: u8,
    /// Collected fees
    coins: Coin<CoinType>
}

/// All `IntregratorFeeStore`s for given `QuoteCoinType`
struct IntegratorFeeStores<QuoteCoinType> has key {
    /// Map from market ID to collected fees
    map: open_table::OpenTable<u64, IntegratorFeeStore<QuoteCoinType>>
}

/// Integrator fee store tier parameters for a given tier
struct IntegratorFeeStoreTierParameters has store {
    /// Nominal amount divisor for fee share reserved for integrator
    /// fee store activated to the given tier. For example, 
    /// if the transaction amount is 1000000 units and the fee divisor
    /// is 5000, the integrator fee store receives 1/5000th, or 0.02%
    /// of, the nominal amount, 200 units, in fees. Instituted as a
    /// divisor for optimized calculations.
    fee_share_divisor: u64,
    /// Cumulative cost, in utility coin units, to activate to the
    /// current tier. For instance if an integrator has already
    /// activated to tier 3, which has a tier activation fee of 1000
    /// units, and tier 4 has a tier activation fee of 10000 units, 
    /// the integrator only has to pay 9000 units to activate to
    /// tier 4.
    tier_activation_fee: u64,
    /// Cost, in utility coin units, to withdraw from an integrator
    /// fee store. Shall never be nonzero, since a disincentive is
    /// required to prevent overly-frequent withdraws and thus
    /// transaction collisions with the matching engine.
    withdrawal_fee: u64,
}

/// Incentive parameters for assorted operations
struct IncentiveParameters has key {
    /// Phantom `CoinType` for the
    /// `aptos_framework::coin::Coin<CoinType>` used for utility
    /// functions. Set to `APT` at mainnet launch, later Econia coin
    utility_coin_type_info: type_info::TypeInfo,
    /// `Coin.value` required to register a market
    market_registration_fee: u64,
    /// `Coin.value` required to register as a custodian
    custodian_registration_fee: u64,
    /// 0-indexed list from tier number to corresponding parameters. 
    integrator_fee_store_tiers: vector<IntegratorFeeStoreTierParameters>
    /// Nominal amount divisor for fee charged to takers. For example, 
    /// if the transaction amount is 1000000 units and the fee divisor
    /// is 2000, takers pay 1/2000th, or 0.05% of, the nominal amount,
    /// 500 units, in fees. Instituted as a divisor for optimized
    /// calculations.
    taker_fee_divisor: u64,
    /// Fee share divisor for amount of fees received by makers, when
    /// matching against a taker.
    maker_fee_share_divisor: u64,
    /// Fee share divisor for amount of fees received by integrators,
    /// assessed as a commission at the time of a maker trade.
    integrator_fee_share_divisor: u64
}

All data structures shall be defined in an incentives.move module, with IncentiveParameters only existing at the Econia address, and EconiaFeeStore and IntegratorFeeStores registered at market hosts' accounts.

Relevant range checking will be required upon initialization/updates to the IncentiveParameters to ensure that the maker/integrator fee shares do not exceed taker fees, and fee assessment calculations will require subtracting the integrator fee store share from the total integrator fee share to determine the amount reserved for Econia.

alnoki commented 2 years ago

Utility coin store

Upon registration of the IncentiveParameters, a UtilityCoinStore shall also be initialized under the Econia account:

/// Container for utility coin fees charged by Econia
struct UtilityCoinStore<CoinType> has key {
    /// Coins collected as utility fees
    utility_coins: Coin<CoinType>
}

When updating the IncentiveParameters.utility_coin_type_info, a new UtilityCoinStore shall be registered if one does not already exist

alnoki commented 2 years ago

Maker fee assessment considerations

If maker fees are represented as a dynamic parameter, then it may be impossible to ensure that quote_ceiling and base_ceiling amounts are appropriately accounted for within a maker's market account: if a limit order matches after the maker fee changes, then the proceeds will be different than expected. One solution is for an Order to be updated with a maker_fee_divisor field, such that maker fees for a given order are fixed at the time the order is placed, but then takers will potentially end up paying different fees against legacy limit orders.

A simpler solution is to simply set the maker fee share to 0, such that taker fees are only distributed among integrators and Econia. After all, any fees/rebates seen my makers will eventually be incorporated to the spread, so it is potentially more straightforward to simply eliminate rebates on their end. Here, they would simply widen the spread, and takers would pay less fees overall. The end result for retail traders is basically no change.

In this implementation, there only need to be two divisors: one for the net integrator fee store, and one for tier-wise fee share that fee store-holding integrators get. The fee-store holding integrator portion is simply decremented form the net amount, and the remainder is reserved for Econia.

Matching engine considerations

A simple implementation involves the matching engine, after running match_verify_fills(), simply take the quote_filled amount and decrement a corresponding net integrator share to administer between fee store-holding integrators and Econia. Here, max and min quote parameters are inclusive of fees, and there could simply be an additional return filed for quote fees assessed.

alnoki commented 2 years ago

Econia resource account

For simplicity of withdrawing fees stored in an EconiaFeeStore, it is proposed that Econia have a resource account with a single EconiaFeeStore for each QuoteCoinType: when hosts register an OrderBook, the resource account will provide a signature such that an optional move_to() can be invoked under the resource account, and an entry will then be inserted to the table within for the new market ID

alnoki commented 2 years ago

Variable tuning challenges

To prevent excessive bogus transactions on the network, it may be necessary to tune incentive parameters after launch. However, in the interest of reducing central points of failure, it is necessary that this tuning not be performed by a sole authority. One possible method is for parameters to be set in a nominal USD price, then have the Aptos coin amounts be calculated off of this via an on-chain Oracle. However this introduces Oracle risk, the necessity for a refresh operation transaction that updates the Aptos coin amounts accordingly, and does not solve the problem of setting operational fees relative to one another.

Multisignature approach

Per #14 , one approach is to launch Econia on mainnet under the authority of a multisignature wallet with mutually-interested yet non-colluding entities, who collectively approve modifications to on-chain variables: the multisignature wallet has full authority over Econia, but 5/8 signatures may be required to approve associated transactions. Further granularity can be handled at the multisignature wallet level, e.g. 5/8 votes to modify fee tier parameters, 7/8 votes to withdraw funds, etc.

Here, the multisignature wallet could have a resource account under which the Econia signing capability is stored, and thus where the EconiaFeeStore and a similar UtilityCoinStore could be stored.

Taker fee distribution

Should Econia launch with taker fees only, evaluating fees can be done a posteriori for market sell matching, but a priori for buys: in the case of a sell, the fee divisor can be evaluated against quote currency after all matching is done, but in the case of a buy, it will be necessary to pre-deduct quote currency prior to each fill to ensure there is enough quote on hand:

$$ size{fill} (1 + \frac{1}{divisor}) = quote{left} $$

$$ size{fill} = \frac{quote{left}}{1 + \frac{1}{divisor}} $$

$$ size{fill} = \frac{divisor * quote{left}}{divisor + 1} $$

// Size to fill user-side against maker
let size_to_fill = ((divisor as u128) * (quote_left as u128)) / ((divisor as u128) + 1);
// Additional quote to deduct from taker
// Intermediate quote calculations won't overflow since range-checked
// prior to going up on book
let fees_to_deduct = ((size_to_fill * (price as u128) * (tick_size as u128)) / (divisor as u128);

Here, fees granularity is quote coin subunits, even though fill granularity is tick size

alnoki commented 2 years ago

Module dependencies

registry.move and market.move could both use incentives.move, where the above structs and on-chain parameters are defined

alnoki commented 2 years ago

Integrator address handling

If the integrator address passed to a move API does not have a fee store initialized for the given market, or if the integrator address is passed as @Econia, all taker fees shall be routed to a corresponding EconiaFeeStore.