Synthetixio / synthetix

Synthetix Solidity smart contracts
https://synthetix.io/
MIT License
1.21k stars 599 forks source link

Exchange Queuing Proposal #298

Open jjgonecrypto opened 5 years ago

jjgonecrypto commented 5 years ago

Problem

Currently when exchange() is invoked in Synthetix, the function is called with a source synth, an amount in the source synth, and the destination synth. The exchange is processed immediately given the current on-chain pricing in the SNX system. The code states:

  1. Burn the amount of source synth
  2. Calculate how much amount of source synth is in destination synth (amountReceived)
  3. Deduct a fee when required from amountReceived, updating it, and Issue an XDR synth for that fee
  4. Issue the amountReceived to the user in the destination synth

Step 2 above calculates the effectiveValue of amount in source to an equivalent in destination, given the current rates of exchange of both source and destination to USD in ExchangeRates.

An exchange itself does NOT impact the size of the debt pool in any way, as it is simply a repricing of debt - a conversion between from one synth to another. Yet when the prices of synths change from oracle updates to ExchangeRates - then the debt pool is affected.

The current SNX exchange mechanism works like a market order. The user is getting the current market price between source to destination, given the rates the SNX oracle has placed on chain.

This entire flow however, exposes front-running risk. As long as an exchange is mined before the SNX oracle updates, any knowledge of what will happen in the upcoming update can be profited from.

We are currently working around this with SIP-12, however this is only really a temporary fix to the problem.

Proposal

Instead of immediately processing an exchange, it is placed into a queue, along with the current blockHeight. This queue could be processed by anybody at any time, with the exchanges in the queue only filled when their source and destination are updated by an oracle. This then prevents any front-running for good.

This functionality would also support limit orders - allowing users to add orders to exchange when the rate between the source and destination reached a certain threshold.

Concerns

1. Processing the queue - who pays the cost

The obvious question is, who processes the queue, and thus who pays the gas? Each exchange costs around 200k in gas (exchangeGasUsage). Making the user transact twice - once to put it in the queue and once to process it - is too much friction.

Instead, we can create a function process() that anyone can call to process the queue and recover their spent gas costs in sETH. This amount should be the equivalent of tx.gasprice * exchangeGasUsage for each successfully processed exchange.

I propose that the payment of this come from the exchange itself, with a user-placed maxGwei cap on how much this fee can reach to prevent griefing attacks (where by malicious users invoke process() with enough gas to take significant portions of the exchange away from the exchanger).

To ensure the exchange volume is sufficient to pay the processing fee, I propose that for exchanges less than some configurable USD minThreshold amount, we allow them to be executed immediately, bypassing the queue. This alleviates the issue where small exchanges that can't afford their gas processing costs get stuck in the queue. This amount should be small enough that spamming the system with a large amount of these would not alllow profitability of potential front-runners when accounting for gas costs.

One downside of this approach is the added friction to the user who now has to add a maxGwei amount to their exchange. To mitigate the complexity of this, I suggest we prefill this amount in our dApps using the best estimates of current average & fast gas, helping them customize it if need be.

Alternate approach: Meta-transactions & relayers

An alternative approach to the payer concern is to set up something akin to the Gas Station Network (GSN), with a Relayer paying the gas costs. The upside of this approach is that users could sign meta-transactions to a relayer without paying a gas fee. The fee could then be deducted as with Option A) above.

The trouble with this approach is that the system needs to prove the user actually broadcast their meta-transaction at a specific block in order to ensure the delay of processing is legitimate. Morever, even solving for this, using a Relayer creates a point of friction where users cannot directly place their exchange on-chain but rather have to go through a Relayer in order to demonstrate a delay between when they placed an order. This adds more complexity to our dapps and prevents users interacting with the contracts directly using explorers like Etherscan and MyEtherWallet.

2. Reasonable timing

The next question is when is the queue processed? Or in other words, how long should a user expect to wait for their exchange to be executed (or their order to be filled, in the parlance of traditional order-book exchanges).

This is particularly important when using decentralized Chainlink oracles, which are targeting a twice-daily heartbeat. If a price does not move outside the 1% threshold, then it's possible to not receive a price in a 12 hour period. Obviously this is an inordinate length of time to ask any user to wait. To mitigate this, I propose we have some number of blockDelay, after which, an exchange can be processed, even when an on-chain price has not been received from an oracle.

While this does mean that users could still trade on price movements off-chain that have not been reflected on-chain, the profits they can make are negligble the longer the blockDelay is, given that price deviations at 1% or more would be broadcast.

Implementation

The Synthetix contract would need an array of QueueEntry items, along with a new addToQueue() and process() functions.

public minThreshold: uint = ...;
public gasPerExchange: unt = ...;

struct QueueEntry {
  address sender;
  bytes32 source;
  uint amount;
  bytes32 destination;
  uint maxGwei;
  uint block;

  // for limit orders
  uint rate;
  uint expiry;
}

QueueEntry[] public queue;

public function exchange(bytes32 source, uint amount, bytes32 destination, uint maxGwei) {
  if amount less or equal to minThreshold
    _internalExchange(source, amount, destination)
  else
    push new QueueEntry to queue
}

public function availableToProcess(gasPrice: uint): uint {
  return count of (filter queue where each item canProcess at gasPrice)
}

public function canProcess(item: QueueItem, gasPrice: uint): bool {
  return
    // ensure cannot surpass gas limit
    gasPrice less or equal to item.maxGwei AND
    // ensure price has been received or delay hit
    (
      source & destination rates are newer than item.block OR
      block.number is greater than item.block + blockDelay
    ) AND
    // ensure limit requirements if any
    (item is not limit OR item.rate is at or below current market rate and not expired))
}

public function process() {
  for each queue item
    if gasleft() less than exchangeGasUsage
    then
      return
    if canProcess item at tx.gasprice
    then
      gasFee = tx.gasprice * exchangeGasUsage
      execute exchange for (item.amount - gasFee) for item.sender
      execute exchange for gasFee from item.sender to message.sender in sETH
      delete item from queue

    // cleanup old limit orders
    else if block.number >= item.expiry
    then
      delete item from queue
}

Example

Let's say the minThreshold to use the queue is 5 sUSD total exchange size. And the exchangeGasUsage is 200k.

Now let's imagine we're at block 500 and the queue is empty. sETH is at the rate of 200, being last updated at block 450 and sBTC is at 8,000, last updated at block 495.

  1. At block 500 Alice invokes exchange for 5 sETH into sBTC, maxGwei = 5.

    At the time of the exchange, 5 ETH would be worth 1,000 sUSD, or 0.125 sBTC (for reference, the ETHBTC rate here is 200 / 8000, or 0.025).

  2. At block 501 Bob reads availableToProcess(gasPrice: 5) and it returns 0 as both the latest sETH block update is less than 500.

  3. At block 501 another user Christina submits an exchange for 1 sUSD into sETH. As it's below minThreshold, it is processed immediately.

  4. At block 504, sETH is updated to 205 by an oracle. The ETHBTC rate is now 205/8000. As Alice is looking to move out of (or selling) ETH, this movement is favorable to her.

  5. At block 505 Bob again reads availableToProcess(gasPrice: 5) and it still returns 0 (as sBTC is still stale).

  6. At block 510, sBTC is updated to 7900 by an oracle. The ETHBTC rate is now 205/7900. As Alice is looking to move into (or buying) BTC, this movement is favorable to her.

  7. At block 510, Bob reads availableToProcess(gasPrice: 5) which returns 1. Trying availableToProcess(gasPrice: 5) returns 0.

  8. At block 511, Bob invokes process() with a gasPrice of 5 gwei. The first and only entry in the queue is found as the source and destination have a higher blockHeight than the queue entry, so it is processed.

    • The cost of the transaction is calculated at 200,000 * 0.000000005 = 0.001 sETH (which is the equivalent of 0.205 sUSD, or 0.02%).
    • This leaves the exchange with 4.999 sETH. Subtracting the 0.3% exchange fee yields 4.985003 sETH (or ~1022 sUSD worth).
    • Alice has 5 sETH burned and is issued 0.1293317234 sBTC. Bob is issued 0.001 sETH. The fee pool is issued 0.014997 sETH at the equivalent XDR rate.
jjgonecrypto commented 5 years ago

A good point was raised on Twitter today that atomic swaps - such as the Uniswap synth exchange contract - would also be impacted under this proposal. For example, if a user wanted to use that contract to swap ETH for sBTC say, via the deep sETH pool in Uniswap, then they would have to accept that their sBTC would not arrive immediately, but rather after a short delay - not unlike an order being filled on an exchange.

To put it another way, the crux of this proposal is to migrate from a synchronous atomic swap of one synth to another in a single transaction (based on the current on-chain market price) to an asynchronous model whereby the user indicates an intent to exchange, and their order is filled once prices are updates are received or a reasonable delay expires - whichever comes first.

jjgonecrypto commented 4 years ago

I propose closing this proposal in favor of Fee Reclamations: https://github.com/Synthetixio/SIPs/issues/57