o1js
Version:
TypeScript framework for zk-SNARKs and zkApps
385 lines (339 loc) • 13.2 kB
text/typescript
/**
* This DEX implementation differs from ./dex.ts in two ways:
* - More minimal & realistic; stuff designed only for testing protocol features was removed
* - Uses an async pattern with actions that lets users claim funds later and reduces account updates
*
* Warning: The reducer API in o1js is currently not safe to use in production applications. The `reduce()`
* method breaks if more than the hard-coded number (default: 32) of actions are pending. Work is actively
* in progress to mitigate this limitation.
*/
import {
Account,
AccountUpdate,
AccountUpdateForest,
Field,
InferProvable,
Mina,
Permissions,
Provable,
PublicKey,
Reducer,
SmartContract,
State,
Struct,
TokenContract,
TokenId,
UInt64,
method,
state,
} from 'o1js';
import { randomAccounts } from './dex.js';
import { TrivialCoin } from './erc20.js';
export { Dex, DexTokenHolder, addresses, getTokenBalances, keys, tokenIds };
class RedeemAction extends Struct({ address: PublicKey, dl: UInt64 }) {}
class Dex extends TokenContract {
// addresses of token contracts are constants
tokenX = addresses.tokenX;
tokenY = addresses.tokenY;
// Approvable API
async approveBase(forest: AccountUpdateForest) {
this.checkZeroBalanceChange(forest);
}
/**
* state that keeps track of total lqXY supply -- this is needed to calculate what to return when redeeming liquidity
*
* total supply is initially zero; it increases when supplying liquidity and decreases when redeeming it
*/
totalSupply = State<UInt64>();
/**
* redeeming liquidity is a 2-step process leveraging actions, to get past the account update limit
*/
reducer = Reducer({ actionType: RedeemAction });
events = {
'supply-liquidity': Struct({ address: PublicKey, dx: UInt64, dy: UInt64 }),
'redeem-liquidity': Struct({ address: PublicKey, dl: UInt64 }),
};
// better-typed wrapper for `this.emitEvent()`. TODO: remove after fixing event typing
get typedEvents() {
return getTypedEvents<Dex>(this);
}
/**
* Initialization. _All_ permissions are set to impossible except the explicitly required permissions.
*/
init() {
super.init();
let proof = Permissions.proof();
this.account.permissions.set({
...Permissions.allImpossible(),
access: proof,
editState: proof,
editActionState: proof,
send: proof,
});
}
// TODO this could just use `this.approveAccountUpdate()` instead of a separate @method
async createAccount() {
this.internal.mint({
// unconstrained because we don't care which account is created
address: this.sender.getUnconstrained(),
amount: UInt64.from(0),
});
}
/**
* 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.
*/
.returns(UInt64)
async supplyLiquidityBase(dx: UInt64, dy: UInt64) {
// unconstrained because `transfer()` requires sender signature anyway
let user = this.sender.getUnconstrained();
let tokenX = new TrivialCoin(this.tokenX);
let tokenY = new TrivialCoin(this.tokenY);
// get balances of X and Y token
let dexX = AccountUpdate.create(this.address, tokenX.deriveTokenId());
let x = dexX.account.balance.getAndRequireEquals();
let dexY = AccountUpdate.create(this.address, tokenY.deriveTokenId());
let y = dexY.account.balance.getAndRequireEquals();
// // assert dy === [dx * y/x], or x === 0
let isXZero = x.equals(UInt64.zero);
let xSafe = Provable.if(isXZero, UInt64.one, x);
let isDyCorrect = dy.equals(dx.mul(y).div(xSafe));
isDyCorrect.or(isXZero).assertTrue();
await tokenX.transfer(user, dexX, dx);
await tokenY.transfer(user, dexY, dy);
// calculate liquidity token output simply as dl = dx + dx
// => maintains ratio x/l, y/l
let dl = dy.add(dx);
this.internal.mint({ address: user, amount: dl });
// update l supply
let l = this.totalSupply.get();
this.totalSupply.requireEquals(l);
this.totalSupply.set(l.add(dl));
// emit event
this.typedEvents.emit('supply-liquidity', { address: user, dx, dy });
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
*
* The transaction needs to be signed by the user's private key.
*
* NOTE: this does not give back tokens in return for liquidity right away.
* to get back the tokens, you have to call {@link DexTokenHolder}.redeemFinalize()
* on both token holder contracts, after `redeemInitialize()` has been accepted into a block.
*
* @emits RedeemAction - action on the Dex account that will make the token holder
* contracts pay you tokens when reducing the action.
*/
async redeemInitialize(dl: UInt64) {
let sender = this.sender.getUnconstrained(); // unconstrained because `burn()` requires sender signature anyway
this.reducer.dispatch(new RedeemAction({ address: sender, dl }));
this.internal.burn({ address: sender, amount: dl });
// TODO: preconditioning on the state here ruins concurrent interactions,
// there should be another `finalize` DEX method which reduces actions & updates state
this.totalSupply.set(this.totalSupply.getAndRequireEquals().sub(dl));
// emit event
this.typedEvents.emit('redeem-liquidity', { address: sender, dl });
}
/**
* Helper for `DexTokenHolder.redeemFinalize()` which adds preconditions on
* the current action state and token supply
*/
async assertActionsAndSupply(actionState: Field, totalSupply: UInt64) {
this.account.actionState.requireEquals(actionState);
this.totalSupply.requireEquals(totalSupply);
}
/**
* 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.
*
* Note: this is not a `@method`, since it doesn't do anything beyond
* the called methods which requires proof authorization.
*/
async swapX(dx: UInt64) {
let user = this.sender.getUnconstrained(); // unconstrained because `swap()` requires sender signature anyway
let tokenY = new TrivialCoin(this.tokenY);
let dexY = new DexTokenHolder(this.address, tokenY.deriveTokenId());
let dy = await dexY.swap(user, dx, this.tokenX);
await tokenY.transfer(dexY.self, user, 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.
*
* Note: this is not a `@method`, since it doesn't do anything beyond
* the called methods which requires proof authorization.
*/
async swapY(dy: UInt64) {
let user = this.sender.getUnconstrained(); // unconstrained because `swap()` requires sender signature anyway
let tokenX = new TrivialCoin(this.tokenX);
let dexX = new DexTokenHolder(this.address, tokenX.deriveTokenId());
let dx = await dexX.swap(user, dy, this.tokenY);
await tokenX.transfer(dexX.self, user, dx);
return dx;
}
}
class DexTokenHolder extends SmartContract {
redeemActionState = State<Field>();
static redeemActionBatchSize = 5;
events = {
swap: Struct({ address: PublicKey, dx: UInt64 }),
};
// better-typed wrapper for `this.emitEvent()`. TODO: remove after fixing event typing
get typedEvents() {
return getTypedEvents<DexTokenHolder>(this);
}
init() {
super.init();
this.redeemActionState.set(Reducer.initialActionState);
}
async redeemLiquidityFinalize() {
// get redeem actions
let dex = new Dex(this.address);
let fromActionState = this.redeemActionState.getAndRequireEquals();
let actions = dex.reducer.getActions({ fromActionState });
// get total supply of liquidity tokens _before_ applying these actions
// (each redeem action _decreases_ the supply, so we increase it here)
let l = Provable.witness(UInt64, () => {
let l = dex.totalSupply.get().toBigInt();
// dex.totalSupply.assertNothing();
for (let action of actions.data.get()) {
l += action.element.data.get()[0].element.dl.toBigInt();
}
return l;
});
// get our token balance
let x = this.account.balance.getAndRequireEquals();
dex.reducer.forEach(
actions,
({ address, dl }) => {
// for every user that redeemed liquidity, we calculate the token output
// and create a child account update which pays the user
let dx = x.mul(dl).div(l);
let receiver = this.send({ to: address, amount: dx });
// note: this should just work when the reducer gives us dummy data
// important: these child account updates inherit token permission from us
receiver.body.mayUseToken = AccountUpdate.MayUseToken.InheritFromParent;
// update l and x accordingly
l = l.sub(dl);
x = x.add(dx);
},
{
maxUpdatesWithActions: DexTokenHolder.redeemActionBatchSize,
// DEX contract doesn't allow setting preconditions from outside (= w/o proof)
skipActionStatePrecondition: true,
}
);
// update action state so these payments can't be triggered a 2nd time
this.redeemActionState.set(actions.hash);
// precondition on the DEX contract, to prove we used the right actions & token supply
await dex.assertActionsAndSupply(actions.hash, l);
}
// this works for both directions (in our case where both tokens use the same contract)
.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 TrivialCoin(otherTokenAddress);
// get balances of X and Y token
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);
// emit event
this.typedEvents.emit('swap', { address: user, dx });
return dy;
}
}
let { keys, addresses } = randomAccounts(
process.env.USE_CUSTOM_LOCAL_NETWORK === 'true',
'tokenX',
'tokenY',
'dex',
'user'
);
let tokenIds = {
X: TokenId.derive(addresses.tokenX),
Y: TokenId.derive(addresses.tokenY),
lqXY: TokenId.derive(addresses.dex),
};
/**
* Helper to get the various token balances for checks in tests
*/
function getTokenBalances() {
let balances = {
user: { MINA: 0n, X: 0n, Y: 0n, lqXY: 0n },
dex: { X: 0n, Y: 0n, lqXYSupply: 0n },
};
for (let user of ['user'] 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 {
let dex = new Dex(addresses.dex);
balances.dex.lqXYSupply = dex.totalSupply.get().toBigInt();
} catch {}
return balances;
}
function getTypedEvents<Contract extends SmartContract>(contract: Contract) {
return {
emit<Key extends keyof Contract['events']>(
key: Key,
event: InferProvable<Contract['events'][Key]>
) {
contract.emitEvent(key, event);
},
};
}