o1js
Version:
TypeScript framework for zk-SNARKs and zkApps
534 lines (495 loc) • 21.3 kB
text/typescript
import { expect } from 'expect';
import { AccountUpdate, Mina, Permissions, TokenId, UInt64 } from 'o1js';
import { getProfiler } from '../../utils/profiler.js';
import { TokenContract, addresses, createDex, keys, tokenIds } from './dex.js';
let proofsEnabled = false;
let Local = await Mina.LocalBlockchain({
proofsEnabled,
enforceTransactionLimits: false,
});
Mina.setActiveInstance(Local);
let [feePayer] = Local.testAccounts;
let tx, balances, oldBalances;
console.log('-------------------------------------------------');
console.log('FEE PAYER\t', feePayer.toBase58());
console.log('TOKEN X ADDRESS\t', addresses.tokenX.toBase58());
console.log('TOKEN Y ADDRESS\t', addresses.tokenY.toBase58());
console.log('DEX ADDRESS\t', addresses.dex.toBase58());
console.log('USER ADDRESS\t', addresses.user.toBase58());
console.log('-------------------------------------------------');
console.log('TOKEN X ID\t', TokenId.toBase58(tokenIds.X));
console.log('TOKEN Y ID\t', TokenId.toBase58(tokenIds.Y));
console.log('-------------------------------------------------');
await TokenContract.analyzeMethods();
if (proofsEnabled) {
console.log('compile (token)...');
await TokenContract.compile();
}
await main({ withVesting: false });
// swap out ledger so we can start fresh
Local = await Mina.LocalBlockchain({
proofsEnabled,
enforceTransactionLimits: false,
});
Mina.setActiveInstance(Local);
feePayer = Local.testAccounts[0];
await main({ withVesting: true });
console.log('all dex tests were successful! 🎉');
async function main({ withVesting }: { withVesting: boolean }) {
const DexProfiler = getProfiler(
`DEX testing ${withVesting ? 'with vesting' : ''}`
);
DexProfiler.start('DEX test flow');
if (withVesting) console.log('\nWITH VESTING');
else console.log('\nNO VESTING');
let options = withVesting ? { lockedLiquiditySlots: 2 } : undefined;
let { Dex, DexTokenHolder, getTokenBalances } = createDex(options);
// analyze methods for quick error feedback
await DexTokenHolder.analyzeMethods();
await Dex.analyzeMethods();
if (proofsEnabled) {
// compile & deploy all zkApps
console.log('compile (dex token holder)...');
await DexTokenHolder.compile();
console.log('compile (dex main contract)...');
await Dex.compile();
}
let tokenX = new TokenContract(addresses.tokenX);
let tokenY = new TokenContract(addresses.tokenY);
let dex = new Dex(addresses.dex);
let dexTokenHolderX = new DexTokenHolder(addresses.dex, tokenIds.X);
let dexTokenHolderY = new DexTokenHolder(addresses.dex, tokenIds.Y);
console.log('deploy & init token contracts...');
tx = await Mina.transaction(feePayer, async () => {
await tokenX.deploy();
await tokenY.deploy();
// pay fees for creating 2 token contract accounts, and fund them so each can create 1 account themselves
const accountFee = Mina.getNetworkConstants().accountCreationFee;
let feePayerUpdate = AccountUpdate.fundNewAccount(feePayer, 2);
feePayerUpdate.send({ to: tokenX.self, amount: accountFee.mul(2) });
feePayerUpdate.send({ to: tokenY.self, amount: accountFee.mul(2) });
});
await tx.prove();
tx.sign([feePayer.key, keys.tokenX, keys.tokenY]);
await tx.send();
balances = getTokenBalances();
console.log(
'Token contract tokens (X, Y):',
balances.tokenContract.X,
balances.tokenContract.Y
);
console.log('deploy dex contracts...');
tx = await Mina.transaction(feePayer, async () => {
// pay fees for creating 3 dex accounts
AccountUpdate.fundNewAccount(feePayer, 3);
await dex.deploy();
await dexTokenHolderX.deploy();
await tokenX.approveAccountUpdate(dexTokenHolderX.self);
await dexTokenHolderY.deploy();
await tokenY.approveAccountUpdate(dexTokenHolderY.self);
});
await tx.prove();
tx.sign([feePayer.key, keys.dex]);
await tx.send();
console.log('transfer tokens to user');
tx = await Mina.transaction(
{
sender: feePayer,
fee: Mina.getNetworkConstants().accountCreationFee.mul(1),
},
async () => {
let au = AccountUpdate.fundNewAccount(feePayer, 4);
au.send({ to: addresses.user, amount: 20e9 }); // give users MINA to pay fees
au.send({ to: addresses.user2, amount: 20e9 });
// transfer to fee payer so they can provide initial liquidity
await tokenX.transfer(addresses.tokenX, feePayer, 10_000);
await tokenY.transfer(addresses.tokenY, feePayer, 10_000);
// mint tokens to the user (this is additional to the tokens minted at the beginning, so we can overflow the balance
await tokenX.init2();
await tokenY.init2();
}
);
await tx.prove();
tx.sign([feePayer.key, keys.tokenX, keys.tokenY]);
await tx.send();
[ ] = [balances, getTokenBalances()];
console.log('User tokens (X, Y):', balances.user.X, balances.user.Y);
console.log('User MINA:', balances.user.MINA);
// supply the initial liquidity where the token ratio can be arbitrary
console.log('supply liquidity -- base');
tx = await Mina.transaction(
{
sender: feePayer,
fee: Mina.getNetworkConstants().accountCreationFee,
},
async () => {
AccountUpdate.fundNewAccount(feePayer);
await dex.supplyLiquidityBase(UInt64.from(10_000), UInt64.from(10_000));
}
);
await tx.prove();
tx.sign([feePayer.key]);
await tx.send();
[ ] = [balances, getTokenBalances()];
console.log('DEX liquidity (X, Y):', balances.dex.X, balances.dex.Y);
/**
* SUPPLY LIQUIDITY
*
* Happy path (lqXY token was not created for user’s account before)
*
* Test Preconditions:
* - Tokens X and Y created;
* - Some amount of both tokens minted (balances > 0) and available for user's token account;
* - Initial liquidity provided to the DEX contract, so that there exists a token X : Y ratio
* from which to calculate required liquidity inputs
*/
expect(balances.tokenContract.X).toBeGreaterThan(0n);
expect(balances.tokenContract.Y).toBeGreaterThan(0n);
expect(balances.user.X).toBeGreaterThan(0n);
expect(balances.user.Y).toBeGreaterThan(0n);
expect(balances.total.lqXY).toBeGreaterThan(0n);
/**
* Actions:
* - User calls the “Supply Liquidity” smart contract method providing the required tokens
* account information (if not derived automatically) and tokens amounts one is willing to supply.
* - User provides the account creation fee, to be subtracted from its Mina account
*
* note: we supply much more liquidity here, so we can exercise the overflow failure case after that
*/
let USER_DX = 500_000n;
console.log('user supply liquidity (1)');
tx = await Mina.transaction(addresses.user, async () => {
AccountUpdate.fundNewAccount(addresses.user);
await dex.supplyLiquidity(UInt64.from(USER_DX));
});
await tx.prove();
tx.sign([keys.user]);
await tx.send();
[ ] = [balances, getTokenBalances()];
console.log('DEX liquidity (X, Y):', balances.dex.X, balances.dex.Y);
console.log('user DEX tokens:', balances.user.lqXY);
console.log('user MINA:', balances.user.MINA);
/**
* Expected results:
* - Smart contract transfers specified amount of tokens from user’s account to SC account.
* - Check the balances.
* - SC mints the “lqXY” tokens in the amount calculated based on the current liquidity pool state
* and AMM formula application, consumes the lqXY token creation fee from the user’s default
* token account (in parent tokens, which is Mina) and transfers amount of minted lqXY tokens to user’s account;
*/
expect(balances.user.X).toEqual(oldBalances.user.X - USER_DX);
expect(balances.user.Y).toEqual(
oldBalances.user.Y - (USER_DX * oldBalances.dex.Y) / oldBalances.dex.X
);
expect(balances.user.MINA).toEqual(oldBalances.user.MINA - 1n);
expect(balances.user.lqXY).toEqual(
(USER_DX * oldBalances.total.lqXY) / oldBalances.dex.X
);
/**
* Happy path (lqXY token exists for users account)
*
* Same case but we are checking that no token creation fee is paid by the liquidity supplier.
*
* Note: with vesting, this is a failure case because we can't change timing on an account that currently has an active timing
*/
USER_DX = 1000n;
console.log('user supply liquidity (2)');
tx = await Mina.transaction(addresses.user, async () => {
await dex.supplyLiquidity(UInt64.from(USER_DX));
});
await tx.prove();
tx.sign([keys.user]);
if (!withVesting) {
await tx.send();
[ ] = [balances, getTokenBalances()];
console.log('DEX liquidity (X, Y):', balances.dex.X, balances.dex.Y);
console.log('user DEX tokens:', balances.user.lqXY);
expect(balances.user.X).toEqual(oldBalances.user.X - USER_DX);
expect(balances.user.Y).toEqual(
oldBalances.user.Y - (USER_DX * oldBalances.dex.Y) / oldBalances.dex.X
);
expect(balances.user.MINA).toEqual(oldBalances.user.MINA);
expect(balances.user.lqXY).toEqual(
oldBalances.user.lqXY +
(USER_DX * oldBalances.total.lqXY) / oldBalances.dex.X
);
} else {
await expect(tx.send()).rejects.toThrow(/Update_not_permitted_timing/);
}
/**
* Check the method failures during an attempts to supply liquidity when:
* - There is no token X or Y (or both) created yet for user’s account;
* - There is not enough tokens available for user’s tokens accounts, one is willing to supply;
*/
console.log('supplying with no tokens (should fail)');
tx = await Mina.transaction(addresses.user2, async () => {
AccountUpdate.fundNewAccount(addresses.user2);
await dex.supplyLiquidityBase(UInt64.from(100), UInt64.from(100));
});
await tx.prove();
tx.sign([keys.user2]);
await expect(tx.send()).rejects.toThrow(/Overflow/);
console.log('supplying with insufficient tokens (should fail)');
tx = await Mina.transaction(addresses.user, async () => {
await dex.supplyLiquidityBase(UInt64.from(1e9), UInt64.from(1e9));
});
await tx.prove();
tx.sign([keys.user]);
await expect(tx.send()).rejects.toThrow(/Overflow/);
/**
* - Resulting operation will overflow the SC’s receiving token by type or by any other applicable limits;
*
* note: this throws not at the protocol level, but because the smart contract multiplies two UInt64s which overflow.
* this happens in all DEX contract methods!
* => a targeted test with explicitly constructed account updates might be the better strategy to test overflow
*/
console.log('prepare supplying overflowing liquidity');
tx = await Mina.transaction(feePayer, async () => {
AccountUpdate.fundNewAccount(feePayer);
await tokenY.transfer(
addresses.tokenY,
addresses.tokenX,
UInt64.MAXINT().sub(200_000)
);
});
await tx.prove();
await tx.sign([feePayer.key, keys.tokenY]).send();
console.log('supply overflowing liquidity');
await expect(async () => {
tx = await Mina.transaction(addresses.tokenX, async () => {
await dex.supplyLiquidityBase(
UInt64.MAXINT().sub(200_000),
UInt64.MAXINT().sub(200_000)
);
});
await tx.prove();
tx.sign([keys.tokenX]);
await tx.send();
}).rejects.toThrow();
/**
* - Value transfer is restricted (supplier end: withdrawal is prohibited, receiver end: receiving is prohibited) for one or both accounts.
*/
console.log('prepare test with forbidden send');
tx = await Mina.transaction(addresses.tokenX, async () => {
let tokenXtokenAccount = AccountUpdate.create(addresses.tokenX, tokenIds.X);
tokenXtokenAccount.account.permissions.set({
...Permissions.initial(),
send: Permissions.impossible(),
});
tokenXtokenAccount.requireSignature();
// token X owner approves w/ signature so we don't need another method for this test
let tokenX = AccountUpdate.create(addresses.tokenX);
tokenX.approve(tokenXtokenAccount);
tokenX.requireSignature();
});
await tx.prove();
await tx.sign([keys.tokenX]).send();
console.log('supply with forbidden withdrawal (should fail)');
tx = await Mina.transaction(addresses.tokenX, async () => {
AccountUpdate.fundNewAccount(addresses.tokenX);
await dex.supplyLiquidity(UInt64.from(10));
});
await tx.prove();
await expect(tx.sign([keys.tokenX]).send()).rejects.toThrow(
/Update_not_permitted_balance/
);
[ ] = [balances, getTokenBalances()];
/**
* REDEEM LIQUIDITY
*/
if (withVesting) {
/**
* Happy path (vesting period applied)
* - Same case but this time the “Supply Liquidity” happy path case was processed with vesting period
* applied for lqXY tokens. We’re checking that it is impossible to redeem lqXY tokens without respecting
* the timing first and then we check that tokens can be redeemed once timing conditions are met.
*/
// liquidity is locked for 2 slots
// step forward 1 slot => liquidity not unlocked yet
Local.incrementGlobalSlot(1);
let USER_DL = 100n;
console.log('user redeem liquidity (before liquidity token unlocks)');
tx = await Mina.transaction(addresses.user, async () => {
await dex.redeemLiquidity(UInt64.from(USER_DL));
});
await tx.prove();
tx.sign([keys.user]);
await expect(tx.send()).rejects.toThrow(/Source_minimum_balance_violation/);
// another slot => now it should work
Local.incrementGlobalSlot(1);
}
/**
* Happy path (no vesting applied)
*
* Test Preconditions:
* - The "Supply Liquidity" happy path case was processed with no vesting period for lqXY tokens applied.
* - User has some lqXY tokens
*
* Actions:
* - User calls the “Liquidity Redemption” SC method providing the amount of lqXY tokens one is willing to redeem.
*
* Note: we reuse this logic for successful redemption in the vesting case
*/
let USER_DL = 100n;
console.log('user redeem liquidity');
tx = await Mina.transaction(addresses.user, async () => {
await dex.redeemLiquidity(UInt64.from(USER_DL));
});
await tx.prove();
tx.sign([keys.user]);
await tx.send();
[ ] = [balances, getTokenBalances()];
console.log('DEX liquidity (X, Y):', balances.dex.X, balances.dex.Y);
console.log('user DEX tokens:', balances.user.lqXY);
console.log('User tokens (X, Y):', balances.user.X, balances.user.Y);
/**
* Expected results:
* - Asked amount of lqXY tokens is burned off the user's account;
* - Check the balance before and after.
* - The pool's liquidity will reflect the changes of lqXY tokens supply upon the next "Swap" operation;
* - We probably don’t need to check it here?
* - Calculated amount of X and Y tokens are transferred to user’s tokens accounts;
* - Check balances on sender and receiver sides.
*/
expect(balances.user.lqXY).toEqual(oldBalances.user.lqXY - USER_DL);
expect(balances.total.lqXY).toEqual(oldBalances.total.lqXY - USER_DL);
let [dx, dy] = [
(USER_DL * oldBalances.dex.X) / oldBalances.total.lqXY,
(USER_DL * oldBalances.dex.Y) / oldBalances.total.lqXY,
];
expect(balances.user.X).toEqual(oldBalances.user.X + dx);
expect(balances.user.Y).toEqual(oldBalances.user.Y + dy);
expect(balances.dex.X).toEqual(oldBalances.dex.X - dx);
expect(balances.dex.Y).toEqual(oldBalances.dex.Y - dy);
/**
* Bonus test (supply liquidity): check that now that the lock period is over, we can supply liquidity again
*/
if (withVesting) {
USER_DX = 1000n;
console.log('user supply liquidity -- again, after lock period ended');
tx = await Mina.transaction(addresses.user, async () => {
await dex.supplyLiquidity(UInt64.from(USER_DX));
});
await tx.prove();
await tx.sign([keys.user]).send();
[ ] = [balances, getTokenBalances()];
console.log('User tokens (X, Y):', balances.user.X, balances.user.Y);
expect(balances.user.X).toEqual(oldBalances.user.X - USER_DX);
expect(balances.user.Y).toEqual(
oldBalances.user.Y - (USER_DX * oldBalances.dex.Y) / oldBalances.dex.X
);
}
// tests below are not specific to vesting
if (withVesting) {
DexProfiler.stop().store();
return;
}
/**
* Happy path (tokens creation on receiver side in case of their absence)
* - Same case but we are checking that one of the tokens will be created for the user
* (including fee payment for token creation) in case when it doesn’t exist yet.
*
* Check the method failures during an attempts to redeem lqXY tokens when:
* - Emulate conflicting balance preconditions due to concurrent user interactions
* by packing multiple redemptions into one transaction
*
* note: we transfer some lqXY tokens from `user` to `user2`, then we try to redeem the with both users
* -- which exercises a failure case -- and then redeem them all with `user2` (creating their token accounts)
*/
USER_DL = 80n;
console.log('transfer liquidity tokens to user2');
tx = await Mina.transaction(addresses.user, async () => {
AccountUpdate.fundNewAccount(addresses.user);
await dex.transfer(addresses.user, addresses.user2, UInt64.from(USER_DL));
});
await tx.prove();
await tx.sign([keys.user]).send();
[ ] = [balances, getTokenBalances()];
console.log(
'redeem liquidity with both users in one tx (fails because of conflicting balance preconditions)'
);
tx = await Mina.transaction(addresses.user2, async () => {
AccountUpdate.createSigned(addresses.user2).balance.subInPlace(
Mina.getNetworkConstants().accountCreationFee.mul(2)
);
await dex.redeemLiquidity(UInt64.from(USER_DL));
await dex.redeemLiquidity(UInt64.from(USER_DL));
});
await tx.prove();
tx.sign([keys.user, keys.user2]);
await expect(tx.send()).rejects.toThrow(
/Account_balance_precondition_unsatisfied/
);
console.log('user2 redeem liquidity');
tx = await Mina.transaction(addresses.user2, async () => {
AccountUpdate.createSigned(addresses.user2).balance.subInPlace(
Mina.getNetworkConstants().accountCreationFee.mul(2)
);
await dex.redeemLiquidity(UInt64.from(USER_DL));
});
await tx.prove();
await tx.sign([keys.user2]).send();
[ ] = [balances, getTokenBalances()];
expect(balances.user2.lqXY).toEqual(oldBalances.user2.lqXY - USER_DL);
[ ] = [
(USER_DL * oldBalances.dex.X) / oldBalances.total.lqXY,
(USER_DL * oldBalances.dex.Y) / oldBalances.total.lqXY,
];
expect(balances.user2.X).toEqual(oldBalances.user2.X + dx);
expect(balances.user2.Y).toEqual(oldBalances.user2.Y + dy);
expect(balances.user2.MINA).toEqual(oldBalances.user2.MINA - 2n);
/**
* Check the method failures during an attempts to redeem lqXY tokens when:
* - There is not enough lqXY tokens available for user’s account;
*
* note: user2's account is empty now, so redeeming more liquidity fails
*/
console.log('user2 redeem liquidity (fails because insufficient balance)');
tx = await Mina.transaction(addresses.user2, async () => {
await dex.redeemLiquidity(UInt64.from(1n));
});
await tx.prove();
await expect(tx.sign([keys.user2]).send()).rejects.toThrow(/Overflow/);
[ ] = [balances, getTokenBalances()];
/**
* SWAP
*
* Happy path (both tokens (X and Y) were created for user)
*
* Test Preconditions:
* - User has token accounts;
* - Balance of token X is > 0
* - Liquidity Pool is capable of covering the Swap operation.
*
* Actions:
* - User calls the “Swap” SC method providing the token (X for example) and amount it wants to swap.
*/
USER_DX = 10n;
console.log('swap 10 X for Y');
tx = await Mina.transaction(addresses.user, async () => {
await dex.swapX(UInt64.from(USER_DX));
});
await tx.prove();
await tx.sign([keys.user]).send();
[ ] = [balances, getTokenBalances()];
console.log('User tokens (X, Y):', balances.user.X, balances.user.Y);
/**
* Expected results:
* - SC calculates (using AMM formula and current pool state) the resulting amount of Y token user should receive as the result of the Swap operation;
* - SC withdraws requested amount of X token from user’s account;
* - SC sends to user previously calculated amount of Y tokens;
* - It will be good to check if calculation was done correctly but correctness is not a major concern since we’re checking
* the zkApps/o1js on/off-chain features, not the current application's logic;
* We're checking the balances of both tokens on caller and SC sides.
*/
dy = (USER_DX * oldBalances.dex.Y) / (oldBalances.dex.X + USER_DX);
expect(balances.user.X).toEqual(oldBalances.user.X - USER_DX);
expect(balances.user.Y).toEqual(oldBalances.user.Y + dy);
expect(balances.dex.X).toEqual(oldBalances.dex.X + USER_DX);
expect(balances.dex.Y).toEqual(oldBalances.dex.Y - dy);
// x*y is increasing (the dex doesn't lose money from rounding errors -- the user does)
expect(balances.dex.X * balances.dex.Y).toBeGreaterThanOrEqual(
oldBalances.dex.X * oldBalances.dex.Y
);
DexProfiler.stop().store();
}