You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 the amount of source synth
Calculate how much amount of source synth is in destination synth (amountReceived)
Deduct a fee when required from amountReceived, updating it, and Issue an XDR synth for that fee
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 ordersuint rate;
uint expiry;
}
QueueEntry[] public queue;
publicfunction exchange(bytes32source, uintamount, bytes32destination, uintmaxGwei) {
if amount less or equal to minThreshold_internalExchange(source, amount, destination)
else
push new QueueEntry to queue
}
publicfunction availableToProcess(gasPrice: uint): uint {
return count of (filter queue where each item canProcess at gasPrice)
}
publicfunction 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 ORblock.numberis 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))
}
publicfunction process() {
for each queue itemifgasleft() less than exchangeGasUsage
then
returnif 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 sETHdelete item from queue// cleanup old limit orderselseifblock.number>= item.expiry
then
delete item from queue
}
Example
Let's say the minThreshold to use the queue is 5sUSD 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 500Alice invokes exchange for 5 sETH into sBTC, maxGwei = 5.
At the time of the exchange, 5ETH would be worth 1,000sUSD, or 0.125sBTC (for reference, the ETHBTC rate here is 200 / 8000, or 0.025).
At block 501Bob reads availableToProcess(gasPrice: 5) and it returns 0 as both the latest sETH block update is less than 500.
At block 501 another user Christina submits an exchange for 1 sUSD into sETH. As it's below minThreshold, it is processed immediately.
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.
At block 505Bob again reads availableToProcess(gasPrice: 5) and it still returns 0 (as sBTC is still stale).
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.
At block 510, Bob reads availableToProcess(gasPrice: 5) which returns 1. Trying availableToProcess(gasPrice: 5) returns 0.
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.205sUSD, or 0.02%).
This leaves the exchange with 4.999 sETH. Subtracting the 0.3% exchange fee yields 4.985003sETH (or ~1022sUSD worth).
Alice has 5 sETH burned and is issued 0.1293317234sBTC. Bob is issued 0.001sETH. The fee pool is issued 0.014997sETH at the equivalent XDR rate.
The text was updated successfully, but these errors were encountered:
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.
Problem
Currently when
exchange()
is invoked in Synthetix, the function is called with asource
synth, anamount
in the source synth, and thedestination
synth. The exchange is processed immediately given the current on-chain pricing in the SNX system. The code states:Burn
theamount
ofsource
synthamount
ofsource
synth is indestination
synth (amountReceived
)fee
when required fromamountReceived
, updating it, andIssue
anXDR
synth for thatfee
Issue
theamountReceived
to the user in thedestination
synthStep 2 above calculates the
effectiveValue
ofamount
insource
to an equivalent indestination
, given the current rates of exchange of bothsource
anddestination
toUSD
inExchangeRates
.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
todestination
, 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 currentblockHeight
. Thisqueue
could be processed by anybody at any time, with the exchanges in the queue only filled when theirsource
anddestination
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
anddestination
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 insETH
. This amount should be the equivalent oftx.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 invokeprocess()
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 thequeue
. This alleviates the issue where small exchanges that can't afford their gas processing costs get stuck in thequeue
. 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.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 ofblockDelay
, 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 at1%
or more would be broadcast.Implementation
The
Synthetix
contract would need an array ofQueueEntry
items, along with a newaddToQueue()
andprocess()
functions.Example
Let's say the
minThreshold
to use the queue is5
sUSD
total exchange size. And theexchangeGasUsage
is200k
.Now let's imagine we're at block
500
and thequeue
is empty.sETH
is at the rate of200
, being last updated at block450
andsBTC
is at8,000
, last updated at block495
.At block
500
Alice
invokesexchange
for5 sETH into sBTC
,maxGwei = 5
.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.200,000 * 0.000000005 = 0.001 sETH
(which is the equivalent of0.205
sUSD
, or0.02%
).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 text was updated successfully, but these errors were encountered: