-
Notifications
You must be signed in to change notification settings - Fork 105
/
dex.ts
492 lines (453 loc) · 16.8 KB
/
dex.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
import {
Account,
AccountUpdate,
Bool,
Mina,
PrivateKey,
Provable,
PublicKey,
SmartContract,
State,
Struct,
TokenId,
UInt32,
UInt64,
method,
state,
TokenContract as BaseTokenContract,
AccountUpdateForest,
} from 'o1js';
export { TokenContract, addresses, createDex, keys, randomAccounts, tokenIds };
class UInt64x2 extends Struct([UInt64, UInt64]) {}
function createDex({
lockedLiquiditySlots,
}: { lockedLiquiditySlots?: number } = {}) {
class Dex extends BaseTokenContract {
// addresses of token contracts are constants
tokenX = addresses.tokenX;
tokenY = addresses.tokenY;
// Approvable API
@method
async approveBase(forest: AccountUpdateForest) {
this.checkZeroBalanceChange(forest);
}
/**
* state which keeps track of total lqXY supply -- this is needed to calculate what to return when redeeming liquidity
*
* total supply is zero initially; it increases when supplying liquidity and decreases when redeeming it
*/
@state(UInt64) totalSupply = State<UInt64>();
/**
* Mint liquidity tokens in exchange for X and Y tokens
* @param dx input amount of X tokens
* @param dy input amount of Y tokens
* @return output amount of lqXY tokens
*
* This function fails if the X and Y token amounts don't match the current X/Y ratio in the pool.
* This can also be used if the pool is empty. In that case, there is no check on X/Y;
* instead, the input X and Y amounts determine the initial ratio.
*/
@method.returns(UInt64)
async supplyLiquidityBase(dx: UInt64, dy: UInt64) {
let user = this.sender.getUnconstrained(); // unconstrained because transfer() requires the signature anyway
let tokenX = new TokenContract(this.tokenX);
let tokenY = new TokenContract(this.tokenY);
// get balances of X and Y token
let dexXUpdate = AccountUpdate.create(
this.address,
tokenX.deriveTokenId()
);
let dexXBalance = dexXUpdate.account.balance.getAndRequireEquals();
let dexYUpdate = AccountUpdate.create(
this.address,
tokenY.deriveTokenId()
);
let dexYBalance = dexYUpdate.account.balance.getAndRequireEquals();
// assert dy === [dx * y/x], or x === 0
let isXZero = dexXBalance.equals(UInt64.zero);
let xSafe = Provable.if(isXZero, UInt64.one, dexXBalance);
let isDyCorrect = dy.equals(dx.mul(dexYBalance).div(xSafe));
isDyCorrect.or(isXZero).assertTrue();
await tokenX.transfer(user, dexXUpdate, dx);
await tokenY.transfer(user, dexYUpdate, dy);
// calculate liquidity token output simply as dl = dx + dy
// => maintains ratio x/l, y/l
let dl = dy.add(dx);
let userUpdate = this.internal.mint({ address: user, amount: dl });
if (lockedLiquiditySlots !== undefined) {
/**
* exercise the "timing" (vesting) feature to lock the received liquidity tokens.
*
* THIS IS HERE FOR TESTING!
*
* In reality, the timing feature is a bit awkward to use for time-locking liquidity tokens.
* That's because, if there is currently a vesting schedule on an account, we can't modify it.
* Thus, a liquidity provider would need to wait for their current tokens to unlock before being able to
* supply liquidity again (or, create another account to supply liquidity from).
*/
let amountLocked = dl;
userUpdate.account.timing.set({
initialMinimumBalance: amountLocked,
cliffAmount: amountLocked,
cliffTime: UInt32.from(lockedLiquiditySlots),
vestingIncrement: UInt64.zero,
vestingPeriod: UInt32.one,
});
userUpdate.requireSignature();
}
// update l supply
let l = this.totalSupply.get();
this.totalSupply.requireEquals(l);
this.totalSupply.set(l.add(dl));
return dl;
}
/**
* Mint liquidity tokens in exchange for X and Y tokens
* @param dx input amount of X tokens
* @return output amount of lqXY tokens
*
* This uses supplyLiquidityBase as the circuit, but for convenience,
* the input amount of Y tokens is calculated automatically from the X tokens.
* Fails if the liquidity pool is empty, so can't be used for the first deposit.
*/
async supplyLiquidity(dx: UInt64) {
// calculate dy outside circuit
let x = Mina.getAccount(
this.address,
TokenId.derive(this.tokenX)
).balance;
let y = Mina.getAccount(
this.address,
TokenId.derive(this.tokenY)
).balance;
if (x.value.isConstant() && x.value.equals(0).toBoolean()) {
throw Error(
'Cannot call `supplyLiquidity` when reserves are zero. Use `supplyLiquidityBase`.'
);
}
let dy = dx.mul(y).div(x);
return await this.supplyLiquidityBase(dx, dy);
}
/**
* Burn liquidity tokens to get back X and Y tokens
* @param dl input amount of lqXY token
* @return output amount of X and Y tokens, as a tuple [outputX, outputY]
*
* The transaction needs to be signed by the user's private key.
*
* Note: this is not a `@method` because there's nothing to prove which isn't already proven
* by the called methods
*/
async redeemLiquidity(dl: UInt64) {
// call the token X holder inside a token X-approved callback
let sender = this.sender.getUnconstrained(); // unconstrained because redeemLiquidity() requires the signature anyway
let tokenX = new TokenContract(this.tokenX);
let dexX = new DexTokenHolder(this.address, tokenX.deriveTokenId());
let dxdy = await dexX.redeemLiquidity(sender, dl, this.tokenY);
let dx = dxdy[0];
await tokenX.transfer(dexX.self, sender, dx);
return dxdy;
}
/**
* Swap X tokens for Y tokens
* @param dx input amount of X tokens
* @return output amount Y tokens
*
* The transaction needs to be signed by the user's private key.
*/
@method.returns(UInt64)
async swapX(dx: UInt64) {
let sender = this.sender.getUnconstrained(); // unconstrained because swap() requires the signature anyway
let tokenY = new TokenContract(this.tokenY);
let dexY = new DexTokenHolder(this.address, tokenY.deriveTokenId());
let dy = await dexY.swap(sender, dx, this.tokenX);
await tokenY.transfer(dexY.self, sender, dy);
return dy;
}
/**
* Swap Y tokens for X tokens
* @param dy input amount of Y tokens
* @return output amount Y tokens
*
* The transaction needs to be signed by the user's private key.
*/
@method.returns(UInt64)
async swapY(dy: UInt64) {
let sender = this.sender.getUnconstrained(); // unconstrained because swap() requires the signature anyway
let tokenX = new TokenContract(this.tokenX);
let dexX = new DexTokenHolder(this.address, tokenX.deriveTokenId());
let dx = await dexX.swap(sender, dy, this.tokenY);
await tokenX.transfer(dexX.self, sender, dx);
return dx;
}
/**
* helper method to approve burning of user's liquidity.
* this just burns user tokens, so there is no incentive to call this directly.
* instead, the dex token holders call this and in turn pay back tokens.
*
* @param user caller address
* @param dl input amount of lq tokens
* @returns total supply of lq tokens _before_ burning dl, so that caller can calculate how much dx / dx to returns
*
* The transaction needs to be signed by the user's private key.
*/
@method.returns(UInt64)
async burnLiquidity(user: PublicKey, dl: UInt64) {
// this makes sure there is enough l to burn (user balance stays >= 0), so l stays >= 0, so l was >0 before
this.internal.burn({ address: user, amount: dl });
let l = this.totalSupply.get();
this.totalSupply.requireEquals(l);
this.totalSupply.set(l.sub(dl));
return l;
}
}
class ModifiedDex extends Dex {
async deploy() {
await super.deploy();
// override the isNew requirement for re-deploying
this.account.isNew.requireNothing();
}
@method.returns(UInt64)
async swapX(dx: UInt64) {
let sender = this.sender.getUnconstrained(); // unconstrained because swap() requires the signature anyway
let tokenY = new TokenContract(this.tokenY);
let dexY = new ModifiedDexTokenHolder(
this.address,
tokenY.deriveTokenId()
);
let dy = await dexY.swap(sender, dx, this.tokenX);
await tokenY.transfer(dexY.self, sender, dy);
return dy;
}
}
class DexTokenHolder extends SmartContract {
// simpler circuit for redeeming liquidity -- direct trade between our token and lq token
// it's incomplete, as it gives the user only the Y part for an lqXY token; but doesn't matter as there's no incentive to call it directly
// see the more complicated method `redeemLiquidity` below which gives back both tokens, by calling this method,
// for the other token, in a callback
@method.returns(UInt64x2)
async redeemLiquidityPartial(user: PublicKey, dl: UInt64) {
// user burns dl, approved by the Dex main contract
let dex = new Dex(addresses.dex);
let l = await dex.burnLiquidity(user, dl);
// in return, we give dy back
let y = this.account.balance.get();
this.account.balance.requireEquals(y);
// we can safely divide by l here because the Dex contract logic wouldn't allow burnLiquidity if not l>0
let dy = y.mul(dl).div(l);
// just subtract the balance, user gets their part one level higher
this.balance.subInPlace(dy);
// be approved by the token owner parent
this.self.body.mayUseToken = AccountUpdate.MayUseToken.ParentsOwnToken;
// return l, dy so callers don't have to walk their child account updates to get it
return [l, dy];
}
// more complicated circuit, where we trigger the Y(other)-lqXY trade in our child account updates and then add the X(our) part
@method.returns(UInt64x2)
async redeemLiquidity(
user: PublicKey,
dl: UInt64,
otherTokenAddress: PublicKey
) {
// first call the Y token holder, approved by the Y token contract; this makes sure we get dl, the user's lqXY
let tokenY = new TokenContract(otherTokenAddress);
let dexY = new DexTokenHolder(this.address, tokenY.deriveTokenId());
let result = await dexY.redeemLiquidityPartial(user, dl);
let l = result[0];
let dy = result[1];
await tokenY.transfer(dexY.self, user, dy);
// in return for dl, we give back dx, the X token part
let x = this.account.balance.get();
this.account.balance.requireEquals(x);
let dx = x.mul(dl).div(l);
// just subtract the balance, user gets their part one level higher
this.balance.subInPlace(dx);
return [dx, dy];
}
// this works for both directions (in our case where both tokens use the same contract)
@method.returns(UInt64)
async swap(
user: PublicKey,
otherTokenAmount: UInt64,
otherTokenAddress: PublicKey
) {
// we're writing this as if our token === y and other token === x
let dx = otherTokenAmount;
let tokenX = new TokenContract(otherTokenAddress);
// get balances
let dexX = AccountUpdate.create(this.address, tokenX.deriveTokenId());
let x = dexX.account.balance.getAndRequireEquals();
let y = this.account.balance.getAndRequireEquals();
// send x from user to us (i.e., to the same address as this but with the other token)
await tokenX.transfer(user, dexX, dx);
// compute and send dy
let dy = y.mul(dx).div(x.add(dx));
// just subtract dy balance and let adding balance be handled one level higher
this.balance.subInPlace(dy);
return dy;
}
}
class ModifiedDexTokenHolder extends DexTokenHolder {
/**
* This swap method has a slightly changed formula
*/
@method.returns(UInt64)
async swap(
user: PublicKey,
otherTokenAmount: UInt64,
otherTokenAddress: PublicKey
) {
let dx = otherTokenAmount;
let tokenX = new TokenContract(otherTokenAddress);
// get balances
let dexX = AccountUpdate.create(this.address, tokenX.deriveTokenId());
let x = dexX.account.balance.getAndRequireEquals();
let y = this.account.balance.get();
this.account.balance.requireEquals(y);
await tokenX.transfer(user, dexX, dx);
// this formula has been changed - we just give the user an additional 15 token
let dy = y.mul(dx).div(x.add(dx)).add(15);
this.balance.subInPlace(dy);
return dy;
}
}
/**
* Helper to get the various token balances for checks in tests
*/
function getTokenBalances() {
let balances = {
user: { MINA: 0n, X: 0n, Y: 0n, lqXY: 0n },
user2: { MINA: 0n, X: 0n, Y: 0n, lqXY: 0n },
dex: { X: 0n, Y: 0n },
tokenContract: { X: 0n, Y: 0n },
total: { lqXY: 0n },
};
for (let user of ['user', 'user2'] as const) {
try {
balances[user].MINA =
Mina.getBalance(addresses[user]).toBigInt() / 1_000_000_000n;
} catch {}
for (let token of ['X', 'Y', 'lqXY'] as const) {
try {
balances[user][token] = Mina.getBalance(
addresses[user],
tokenIds[token]
).toBigInt();
} catch {}
}
}
try {
balances.dex.X = Mina.getBalance(addresses.dex, tokenIds.X).toBigInt();
} catch {}
try {
balances.dex.Y = Mina.getBalance(addresses.dex, tokenIds.Y).toBigInt();
} catch {}
try {
balances.tokenContract.X = Mina.getBalance(
addresses.tokenX,
tokenIds.X
).toBigInt();
} catch {}
try {
balances.tokenContract.Y = Mina.getBalance(
addresses.tokenY,
tokenIds.Y
).toBigInt();
} catch {}
try {
let dex = new Dex(addresses.dex);
balances.total.lqXY = dex.totalSupply.get().toBigInt();
} catch {}
return balances;
}
return {
Dex,
DexTokenHolder,
ModifiedDexTokenHolder,
ModifiedDex,
getTokenBalances,
};
}
/**
* Simple token with API flexible enough to handle all our use cases
*/
class TokenContract extends BaseTokenContract {
@method async init() {
super.init();
// mint the entire supply to the token account with the same address as this contract
/**
* DUMB STUFF FOR TESTING (change in real app)
*
* we mint the max uint64 of tokens here, so that we can overflow it in tests if we just mint a bit more
*/
let receiver = this.internal.mint({
address: this.address,
amount: UInt64.MAXINT(),
});
// assert that the receiving account is new, so this can be only done once
receiver.account.isNew.requireEquals(Bool(true));
// pay fees for opened account
this.balance.subInPlace(Mina.getNetworkConstants().accountCreationFee);
}
/**
* DUMB STUFF FOR TESTING (delete in real app)
*
* mint additional tokens to some user, so we can overflow token balances
*/
@method async init2() {
let receiver = this.internal.mint({
address: addresses.user,
amount: UInt64.from(10n ** 6n),
});
// assert that the receiving account is new, so this can be only done once
receiver.account.isNew.requireEquals(Bool(true));
// pay fees for opened account
this.balance.subInPlace(Mina.getNetworkConstants().accountCreationFee);
}
@method
async approveBase(forest: AccountUpdateForest) {
this.checkZeroBalanceChange(forest);
}
}
const savedKeys = [
'EKFcUu4FLygkyZR8Ch4F8hxuJps97GCfiMRSWXDP55sgvjcmNGHc',
'EKENfq7tEdTf5dnNxUgVo9dUnAqrEaB9syTgFyuRWinR5gPuZtbG',
'EKEPVj2PDzQUrMwL2yeUikoQYXvh4qrkSxsDa7gegVcDvNjAteS5',
'EKDm7SHWHEP5xiSbu52M1Z4rTFZ5Wx7YMzeaC27BQdPvvGvF42VH',
'EKEuJJmmHNVHD1W2qmwExDyGbkSoKdKmKNPZn8QbqybVfd2Sd4hs',
'EKEyPVU37EGw8CdGtUYnfDcBT2Eu7B6rSdy64R68UHYbrYbVJett',
];
let { keys, addresses } = randomAccounts(
process.env.USE_CUSTOM_LOCAL_NETWORK === 'true',
'tokenX',
'tokenY',
'dex',
'user',
'user2',
'user3'
);
let tokenIds = {
X: TokenId.derive(addresses.tokenX),
Y: TokenId.derive(addresses.tokenY),
lqXY: TokenId.derive(addresses.dex),
};
/**
* Predefined accounts keys, labeled by the input strings. Useful for testing/debugging with consistent keys.
*/
function randomAccounts<K extends string>(
createNewAccounts: boolean,
...names: [K, ...K[]]
): { keys: Record<K, PrivateKey>; addresses: Record<K, PublicKey> } {
let base58Keys = createNewAccounts
? Array(6)
.fill('')
.map(() => PrivateKey.random().toBase58())
: savedKeys;
let keys = Object.fromEntries(
names.map((name, idx) => [name, PrivateKey.fromBase58(base58Keys[idx])])
) as Record<K, PrivateKey>;
let addresses = Object.fromEntries(
names.map((name) => [name, keys[name].toPublicKey()])
) as Record<K, PublicKey>;
return { keys, addresses };
}