synthetix
synthetix copied to clipboard
Exchange Queuing Proposal
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:
-
Burn
theamount
ofsource
synth - Calculate how much
amount
ofsource
synth is indestination
synth (amountReceived
) - Deduct a
fee
when required fromamountReceived
, updating it, andIssue
anXDR
synth for thatfee
-
Issue
theamountReceived
to the user in thedestination
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 aRelayer
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
.
-
At block
500
Alice
invokesexchange
for5 sETH into sBTC
,maxGwei = 5
.At the time of the exchange,
5
ETH
would be worth1,000
sUSD
, or0.125
sBTC
(for reference, theETHBTC
rate here is200 / 8000
, or0.025
). -
At block
501
Bob
readsavailableToProcess(gasPrice: 5)
and it returns0
as both the latestsETH
block update is less than500
. -
At block
501
another userChristina
submits an exchange for 1sUSD
intosETH
. As it's belowminThreshold
, it is processed immediately. -
At block
504
,sETH
is updated to205
by an oracle. TheETHBTC
rate is now205/8000
. AsAlice
is looking to move out of (or selling)ETH
, this movement is favorable to her. -
At block
505
Bob
again readsavailableToProcess(gasPrice: 5)
and it still returns0
(assBTC
is still stale). -
At block
510
,sBTC
is updated to7900
by an oracle. TheETHBTC
rate is now205/7900
. AsAlice
is looking to move into (or buying)BTC
, this movement is favorable to her. -
At block
510
,Bob
readsavailableToProcess(gasPrice: 5)
which returns1
. TryingavailableToProcess(gasPrice: 5)
returns0
. -
At block
511
,Bob
invokesprocess()
with a gasPrice of5
gwei. The first and only entry in the queue is found as thesource
anddestination
have a higherblockHeight
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 of0.205
sUSD
, or0.02%
). - This leaves the exchange with
4.999 sETH
. Subtracting the0.3%
exchange fee yields4.985003
sETH
(or~1022
sUSD
worth). -
Alice
has5 sETH
burned and is issued0.1293317234
sBTC
.Bob
is issued0.001
sETH
. The fee pool is issued0.014997
sETH
at the equivalentXDR
rate.
- The cost of the transaction is calculated at
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.
I propose closing this proposal in favor of Fee Reclamations: https://github.com/Synthetixio/SIPs/issues/57