Skip to content

Commit ffa2d57

Browse files
authored
test: add StablecoinExchange tests (#46)
* test: add stablecoin dex tests * wip: revert mocktip20 * wip: get next order id * wip: test price to tick * wip: update todos * wip: update test names * wip: update place, placeFlip functions * wip: test create pair * wip: place bid test * wip: place ask order * wip: update bid/ask balance checks * wip: place flip balance checks * wip: execute block test * wip: getTickLevel fn * wip: execute block test * wip: fix place/bid order * wip: fix pending order ids * wip: fix execute block test * wip: use price scale * wip: fix cancel tests * wip: fix test buy * wip: fix flip tests * wip: remove side enum * docs: note * test: add place flip test * chore: remove redundant check * test: execute block fails non system tx * fmt: forge fmt * test: multi tick tests * chore: update test name
1 parent da31b96 commit ffa2d57

3 files changed

Lines changed: 622 additions & 61 deletions

File tree

specs/src/StablecoinExchange.sol

Lines changed: 112 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,7 @@ contract StablecoinExchange is IStablecoinExchange {
1616
/// @notice Price scaling factor (5 decimal places for 0.1 bps precision)
1717
uint32 public constant PRICE_SCALE = 100_000;
1818

19-
enum Side {
20-
Bid,
21-
Ask
22-
}
19+
event PairCreated(bytes32 indexed key, address indexed base, address indexed quote);
2320

2421
/// @notice Represents a price level in the orderbook with a doubly-linked list of orders
2522
/// @dev Orders are maintained in FIFO order at each tick level
@@ -39,7 +36,7 @@ contract StablecoinExchange is IStablecoinExchange {
3936
/// Orderbook key
4037
bytes32 bookKey;
4138
/// Bid or ask indicator
42-
Side side;
39+
bool isBid;
4340
/// Price tick
4441
int16 tick;
4542
/// Original order amount
@@ -80,7 +77,6 @@ contract StablecoinExchange is IStablecoinExchange {
8077
/*//////////////////////////////////////////////////////////////
8178
STORAGE
8279
//////////////////////////////////////////////////////////////*/
83-
LinkingUSD public immutable linkingToken;
8480

8581
/// Mapping of pair key to orderbook
8682
mapping(bytes32 pairKey => Orderbook orderbook) internal books;
@@ -92,17 +88,9 @@ contract StablecoinExchange is IStablecoinExchange {
9288
mapping(address user => mapping(address token => uint128 balance)) internal balances;
9389

9490
/// Last processed order ID
95-
uint128 nextOrderId;
91+
uint128 public activeOrderId;
9692
/// Latest pending order ID
97-
uint128 pendingOrderId;
98-
99-
/*//////////////////////////////////////////////////////////////
100-
CONSTRUCTOR
101-
//////////////////////////////////////////////////////////////*/
102-
103-
constructor(address admin) {
104-
linkingToken = new LinkingUSD(admin);
105-
}
93+
uint128 public pendingOrderId;
10694

10795
/*//////////////////////////////////////////////////////////////
10896
Functions
@@ -145,8 +133,6 @@ contract StablecoinExchange is IStablecoinExchange {
145133
}
146134

147135
/// @notice Generate deterministic key for token pair
148-
/// @param tokenA First token address
149-
/// @param tokenB Second token address
150136
/// @return key Deterministic pair key
151137
function pairKey(address tokenA, address tokenB) public pure returns (bytes32 key) {
152138
(tokenA, tokenB) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
@@ -169,6 +155,8 @@ contract StablecoinExchange is IStablecoinExchange {
169155

170156
book.bestBidTick = type(int16).min;
171157
book.bestAskTick = type(int16).max;
158+
159+
emit PairCreated(key, base, quote);
172160
}
173161

174162
/// @notice Internal function to place order in pending queue
@@ -185,6 +173,7 @@ contract StablecoinExchange is IStablecoinExchange {
185173
address base,
186174
address quote,
187175
uint128 amount,
176+
address maker,
188177
bool isBid,
189178
int16 tick,
190179
bool isFlip,
@@ -214,7 +203,7 @@ contract StablecoinExchange is IStablecoinExchange {
214203
// For bids, escrow quote tokens based on price
215204
escrowToken = quote;
216205
uint32 price = tickToPrice(tick);
217-
escrowAmount = (amount * price) / PRICE_SCALE;
206+
escrowAmount = uint128((uint256(amount) * uint256(price)) / uint256(PRICE_SCALE));
218207
} else {
219208
// For asks, escrow base tokens
220209
escrowToken = base;
@@ -230,12 +219,13 @@ contract StablecoinExchange is IStablecoinExchange {
230219
ITIP20(escrowToken).transferFrom(msg.sender, address(this), escrowAmount - userBalance);
231220
}
232221

233-
orderId = ++pendingOrderId;
222+
orderId = pendingOrderId + 1;
223+
++pendingOrderId;
234224

235225
orders[orderId] = Order({
236226
maker: msg.sender,
237227
bookKey: key,
238-
side: isBid ? Side.Bid : Side.Ask,
228+
isBid: isBid,
239229
tick: tick,
240230
amount: amount,
241231
remaining: amount,
@@ -245,26 +235,26 @@ contract StablecoinExchange is IStablecoinExchange {
245235
flipTick: flipTick
246236
});
247237

238+
emit OrderPlaced(orderId, maker, base, amount, isBid, tick);
248239
return orderId;
249240
}
250241

251242
/// @notice Place a limit order on the orderbook
252-
/// @param base Base token address
243+
/// @param token Token address (system determines base/quote pairing)
253244
/// @param amount Order amount in base token
254245
/// @param isBid True for buy orders, false for sell orders
255246
/// @param tick Price tick for the order
256247
/// @return orderId The assigned order ID
257-
function place(address base, uint128 amount, bool isBid, int16 tick)
248+
function place(address token, uint128 amount, bool isBid, int16 tick)
258249
external
259250
returns (uint128 orderId)
260251
{
261-
address quote = ITIP20(base).quoteToken();
262-
orderId = _placeOrder(base, quote, amount, isBid, tick, false, 0);
263-
emit OrderPlaced(orderId, msg.sender, base, amount, isBid, tick);
252+
address quote = ITIP20(token).quoteToken();
253+
orderId = _placeOrder(token, quote, amount, msg.sender, isBid, tick, false, 0);
264254
}
265255

266256
/// @notice Place a flip order that auto-flips when filled
267-
/// @param token Token address (base token)
257+
/// @param token Token address
268258
/// @param amount Order amount in base token
269259
/// @param isBid True for bid (buy), false for ask (sell)
270260
/// @param tick Price tick for the order
@@ -275,7 +265,7 @@ contract StablecoinExchange is IStablecoinExchange {
275265
returns (uint128 orderId)
276266
{
277267
address quote = ITIP20(token).quoteToken();
278-
orderId = _placeOrder(token, quote, amount, isBid, tick, true, flipTick);
268+
orderId = _placeOrder(token, quote, amount, msg.sender, isBid, tick, true, flipTick);
279269
emit FlipOrderPlaced(orderId, msg.sender, token, amount, isBid, tick, flipTick);
280270
}
281271

@@ -285,62 +275,86 @@ contract StablecoinExchange is IStablecoinExchange {
285275
require(order.maker == msg.sender, "UNAUTHORIZED");
286276

287277
Orderbook storage book = books[order.bookKey];
288-
address token = order.side == Side.Bid ? book.quote : book.base;
278+
address token = order.isBid ? book.quote : book.base;
289279

290280
// If the order is pending, delete it from storage without adjusting the orderbook
291-
if (orderId > nextOrderId) {
292-
// Credit remaining tokens to user's withdrawable balance
293-
balances[order.maker][token] += order.remaining;
281+
if (orderId > activeOrderId) {
282+
// Credit escrow amount to user's withdrawable balance
283+
uint128 escrowAmount;
284+
if (order.isBid) {
285+
// For bids, escrow quote tokens based on price
286+
uint32 price = tickToPrice(order.tick);
287+
escrowAmount =
288+
uint128((uint256(order.remaining) * uint256(price)) / uint256(PRICE_SCALE));
289+
} else {
290+
// For asks, escrow base tokens
291+
escrowAmount = order.remaining;
292+
}
293+
balances[order.maker][token] += escrowAmount;
294294

295295
delete orders[orderId];
296296
emit OrderCancelled(orderId);
297297
return;
298-
}
299-
300-
bool isBid = order.side == Side.Bid;
301-
TickLevel storage level = isBid ? book.bids[order.tick] : book.asks[order.tick];
302-
303-
if (order.prev != 0) {
304-
orders[order.prev].next = order.next;
305298
} else {
306-
level.head = order.next;
307-
}
299+
bool isBid = order.isBid;
300+
TickLevel storage level = isBid ? book.bids[order.tick] : book.asks[order.tick];
308301

309-
if (order.next != 0) {
310-
orders[order.next].prev = order.prev;
311-
} else {
312-
level.tail = order.prev;
313-
}
302+
if (order.prev != 0) {
303+
orders[order.prev].next = order.next;
304+
} else {
305+
level.head = order.next;
306+
}
314307

315-
// Decrement total liquidity
316-
level.totalLiquidity -= order.remaining;
308+
if (order.next != 0) {
309+
orders[order.next].prev = order.prev;
310+
} else {
311+
level.tail = order.prev;
312+
}
317313

318-
if (level.head == 0) {
319-
_clearTickBit(order.bookKey, order.tick, isBid);
320-
}
314+
// Decrement total liquidity
315+
level.totalLiquidity -= order.remaining;
316+
317+
if (level.head == 0) {
318+
_clearTickBit(order.bookKey, order.tick, isBid);
319+
}
321320

322-
// Credit remaining tokens to user's withdrawable balance
323-
balances[order.maker][token] += order.remaining;
321+
// Credit escrow amount to user's withdrawable balance
322+
uint128 escrowAmount;
323+
if (order.isBid) {
324+
// For bids, escrow quote tokens based on price
325+
uint32 price = tickToPrice(order.tick);
326+
escrowAmount =
327+
uint128((uint256(order.remaining) * uint256(price)) / uint256(PRICE_SCALE));
328+
} else {
329+
// For asks, escrow base tokens
330+
escrowAmount = order.remaining;
331+
}
332+
balances[order.maker][token] += escrowAmount;
324333

325-
delete orders[orderId];
334+
delete orders[orderId];
326335

327-
emit OrderCancelled(orderId);
336+
emit OrderCancelled(orderId);
337+
}
328338
}
329339

330340
// TODO: it might be nice to create some ISystem Tx interface that is used
331341
// for contracts that are executed by the protocol at the end of the block.
332342
// This makes it easy to distinguish when the protocol is responsible for calling a function
333343
// TODO: natspec
334344
function executeBlock() external {
335-
while (nextOrderId < pendingOrderId) {
336-
uint128 orderId = ++nextOrderId;
345+
require(msg.sender == address(0), "Only system tx");
346+
347+
uint128 orderId = activeOrderId;
348+
uint128 pendingId = pendingOrderId;
349+
350+
for (orderId = orderId; orderId <= pendingId; orderId++) {
337351
Order storage order = orders[orderId];
338352

339353
// If the order is already canceled, skip
340354
if (order.maker == address(0)) continue;
341355

342356
Orderbook storage book = books[order.bookKey];
343-
bool isBid = order.side == Side.Bid;
357+
bool isBid = order.isBid;
344358
TickLevel storage level = isBid ? book.bids[order.tick] : book.asks[order.tick];
345359

346360
uint128 prevTail = level.tail;
@@ -368,6 +382,9 @@ contract StablecoinExchange is IStablecoinExchange {
368382
// Increment total liquidity for this tick level
369383
level.totalLiquidity += order.remaining;
370384
}
385+
386+
// Update activeOrderId to last processed order
387+
activeOrderId = orderId - 1;
371388
}
372389

373390
/// @notice Withdraw tokens from exchange balance
@@ -388,6 +405,25 @@ contract StablecoinExchange is IStablecoinExchange {
388405
return balances[user][token];
389406
}
390407

408+
/// @notice Get tick level information
409+
/// @param base Base token in pair
410+
/// @param tick Price tick
411+
/// @param isBid boolean to indicate bid/ask
412+
/// @return head First order ID tick
413+
/// @return tail Last order ID tick
414+
/// @return totalLiquidity Total liquidity at tick
415+
function getTickLevel(address base, int16 tick, bool isBid)
416+
external
417+
view
418+
returns (uint128 head, uint128 tail, uint128 totalLiquidity)
419+
{
420+
address quote = ITIP20(base).quoteToken();
421+
bytes32 key = pairKey(base, quote);
422+
Orderbook storage book = books[key];
423+
TickLevel memory level = isBid ? book.bids[tick] : book.asks[tick];
424+
return (level.head, level.tail, level.totalLiquidity);
425+
}
426+
391427
/// @notice Quote the cost to buy a specific amount of tokens
392428
/// @param tokenIn Token to spend
393429
/// @param tokenOut Token to buy
@@ -414,15 +450,18 @@ contract StablecoinExchange is IStablecoinExchange {
414450
internal
415451
returns (uint128 nextOrderAtTick)
416452
{
453+
// NOTE: This can be much more optimized but since this is only a reference contract, readability was prioritized
417454
Order storage order = orders[orderId];
418455
Orderbook storage book = books[order.bookKey];
419-
bool isBid = order.side == Side.Bid;
456+
bool isBid = order.isBid;
420457
TickLevel storage level = isBid ? book.bids[order.tick] : book.asks[order.tick];
421458

422459
// Fill the order
423460
order.remaining -= fillAmount;
424461
level.totalLiquidity -= fillAmount;
425462

463+
emit OrderFilled(orderId, order.maker, msg.sender, fillAmount, order.remaining > 0);
464+
426465
// Credit maker with appropriate tokens
427466
if (isBid) {
428467
// Bid order: maker gets base tokens
@@ -451,7 +490,20 @@ contract StablecoinExchange is IStablecoinExchange {
451490
level.tail = order.prev;
452491
}
453492

454-
// TODO: if flip order, place order at flip tick
493+
// If flip order, place order at flip tick on opposite side
494+
if (order.isFlip) {
495+
_placeOrder(
496+
book.base,
497+
book.quote,
498+
order.amount,
499+
order.maker,
500+
!order.isBid,
501+
order.flipTick,
502+
true,
503+
order.tick
504+
);
505+
}
506+
455507
delete orders[orderId];
456508

457509
// Check if tick is exhausted and return 0 if so

specs/src/interfaces/IStablecoinExchange.sol

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ interface IStablecoinExchange {
4848
external
4949
returns (uint128 amountIn);
5050

51-
// View functions
5251
function quoteBuy(address tokenIn, address tokenOut, uint128 amountOut)
5352
external
5453
view

0 commit comments

Comments
 (0)