From 356cc9eedd765e00a3d2d44fa75fe0026368e3d4 Mon Sep 17 00:00:00 2001 From: Raphael Panic Date: Mon, 8 Jul 2024 16:04:57 +0200 Subject: [PATCH 1/2] Added VolumeOracle module --- .../chain/src/runtime/xyk/VolumeOracle.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 packages/chain/src/runtime/xyk/VolumeOracle.ts diff --git a/packages/chain/src/runtime/xyk/VolumeOracle.ts b/packages/chain/src/runtime/xyk/VolumeOracle.ts new file mode 100644 index 0000000..5c75811 --- /dev/null +++ b/packages/chain/src/runtime/xyk/VolumeOracle.ts @@ -0,0 +1,32 @@ +import { runtimeModule, RuntimeModule, state } from "@proto-kit/module"; +import { TokenId, UInt64 } from "@proto-kit/library"; +import { PoolKey } from "./pool-key"; +import { Bool, Field, Int64, Provable, PublicKey, Sign } from "o1js"; +import { StateMap } from "@proto-kit/protocol"; + +@runtimeModule() +export class VolumeOracle extends RuntimeModule { + @state() cumulativeVolume = StateMap.from(PoolKey, UInt64); + @state() netVolume = StateMap.from(PoolKey, Int64); + + public addVolume(pool: PoolKey, direction: Bool, amountA: UInt64): { + cumulativeVolume: UInt64, + netVolume: Int64 + } { + const volumeBefore = new UInt64( + this.cumulativeVolume.get(pool).orElse(UInt64.from(0)).value); + const cumulativeAfter = volumeBefore.add(amountA) + this.cumulativeVolume.set(pool, cumulativeAfter); + + const netBefore = Int64.from(this.netVolume.get(pool).orElse(Int64.from(0))); + const amountInt = Int64.fromField(amountA.value); + const netChange = Provable.if(direction, amountInt, amountInt.neg()); + const netAfter = netBefore.add(netChange) + this.netVolume.set(pool, netAfter); + + return { + cumulativeVolume: cumulativeAfter, + netVolume: netAfter + } + } +} \ No newline at end of file From 799b9ff2d44446972896087772c1121e5b7d2db7 Mon Sep 17 00:00:00 2001 From: Raphael Panic Date: Mon, 8 Jul 2024 18:49:57 +0200 Subject: [PATCH 2/2] Implemented dynamic fees based on volatility --- packages/chain/src/runtime/index.ts | 7 +- .../chain/src/runtime/xyk/VolumeOracle.ts | 84 ++++++++++---- packages/chain/src/runtime/xyk/xyk.ts | 108 +++++++++++++----- packages/chain/test/runtime/xyk/xyk.test.ts | 8 +- 4 files changed, 146 insertions(+), 61 deletions(-) diff --git a/packages/chain/src/runtime/index.ts b/packages/chain/src/runtime/index.ts index 0a0fb94..184ce78 100644 --- a/packages/chain/src/runtime/index.ts +++ b/packages/chain/src/runtime/index.ts @@ -7,10 +7,12 @@ import { SetDelegateProposal } from "./governance/set-delegate-proposal"; import { OutgoingMessages } from "./outgoing-messages"; import { TokenRegistry } from "./token-registry"; import { XYK } from "./xyk/xyk"; +import { VolumeOracle } from "./xyk/VolumeOracle"; export const modules = { Faucet, Locks, + VolumeOracle, Balances: Balances, SetDelegateProposal, OutgoingMessages, @@ -36,8 +38,11 @@ export const config: ModulesConfig< TokenRegistry: {}, XYK: { feeDivider: 1000n, - fee: 3n, // + fee: 3n, // 0.3% + // volumeFee: 0n, + volumeFee: 500n, // 500% at max volatility }, + VolumeOracle: {} }; export default { diff --git a/packages/chain/src/runtime/xyk/VolumeOracle.ts b/packages/chain/src/runtime/xyk/VolumeOracle.ts index 5c75811..eb26726 100644 --- a/packages/chain/src/runtime/xyk/VolumeOracle.ts +++ b/packages/chain/src/runtime/xyk/VolumeOracle.ts @@ -1,32 +1,66 @@ import { runtimeModule, RuntimeModule, state } from "@proto-kit/module"; import { TokenId, UInt64 } from "@proto-kit/library"; import { PoolKey } from "./pool-key"; -import { Bool, Field, Int64, Provable, PublicKey, Sign } from "o1js"; -import { StateMap } from "@proto-kit/protocol"; +import { Bool, Field, Int64, Provable, PublicKey, Sign, Struct } from "o1js"; +import { State, StateMap } from "@proto-kit/protocol"; + +export class PoolBlockPair extends Struct({ + pool: PoolKey, + block: UInt64, +}) {} @runtimeModule() export class VolumeOracle extends RuntimeModule { - @state() cumulativeVolume = StateMap.from(PoolKey, UInt64); - @state() netVolume = StateMap.from(PoolKey, Int64); - - public addVolume(pool: PoolKey, direction: Bool, amountA: UInt64): { - cumulativeVolume: UInt64, - netVolume: Int64 - } { - const volumeBefore = new UInt64( - this.cumulativeVolume.get(pool).orElse(UInt64.from(0)).value); - const cumulativeAfter = volumeBefore.add(amountA) - this.cumulativeVolume.set(pool, cumulativeAfter); - - const netBefore = Int64.from(this.netVolume.get(pool).orElse(Int64.from(0))); - const amountInt = Int64.fromField(amountA.value); - const netChange = Provable.if(direction, amountInt, amountInt.neg()); - const netAfter = netBefore.add(netChange) - this.netVolume.set(pool, netAfter); - - return { - cumulativeVolume: cumulativeAfter, - netVolume: netAfter - } + @state() cumulativeVolume = StateMap.from(PoolBlockPair, UInt64); + @state() netVolume = StateMap.from( + PoolBlockPair, + Int64, + ); + + public getAverage(pool: PoolKey, numValues: number = 3) { + const values: Int64[] = []; + let currentBlock = new UInt64(this.network.block.height.value); + for (let i = 0; i < numValues; i++) { + values.push(this.netVolume.get({ pool, block: currentBlock }).orElse(Int64.zero)); + + currentBlock = currentBlock.sub( + new UInt64(Provable.if(currentBlock.equals(0), Field(0), Field(1))), + ); } -} \ No newline at end of file + + return values.reduce((a, b) => a.add(b)).div(numValues); + } + + public addVolume( + pool: PoolKey, + direction: Bool, + amountA: UInt64, + ): { + cumulativeVolume: UInt64; + netVolume: Int64; + } { + const oracleKey = new PoolBlockPair({ + pool, + block: UInt64.from(this.network.block.height), + }); + + const volumeBefore = new UInt64( + this.cumulativeVolume.get(oracleKey).orElse(UInt64.from(0)).value, + ); + const cumulativeAfter = volumeBefore.add(amountA); + this.cumulativeVolume.set(oracleKey, cumulativeAfter); + + const netBefore = Int64.from( + this.netVolume.get(oracleKey).orElse(Int64.from(0)), + ); + const amountInt = Int64.fromField(amountA.value); + const netChange = Provable.if(direction, amountInt, amountInt.neg()); + const netAfter = netBefore.add(netChange); + this.netVolume.set(oracleKey, netAfter); + + return { + cumulativeVolume: cumulativeAfter, + netVolume: netAfter, + }; + } +} diff --git a/packages/chain/src/runtime/xyk/xyk.ts b/packages/chain/src/runtime/xyk/xyk.ts index 262e09d..d138fc9 100644 --- a/packages/chain/src/runtime/xyk/xyk.ts +++ b/packages/chain/src/runtime/xyk/xyk.ts @@ -13,6 +13,7 @@ import { TokenPair } from "./token-pair"; import { LPTokenId } from "./lp-token-id"; import { MAX_TOKEN_ID, TokenRegistry } from "../token-registry"; import { Balances } from "../balances"; +import { VolumeOracle } from "./VolumeOracle"; export const errors = { tokensNotDistinct: () => `Tokens must be different`, @@ -26,7 +27,7 @@ export const errors = { amountOutIsInsufficient: () => `Amount out is insufficient`, }; -// we need a placeholder pool value until protokit supports value-less dictonaries or state arrays +// we need a placeholder pool value until protokit supports value-less dictionaries or state arrays export const placeholderPoolValue = Bool(true); export const MAX_PATH_LENGTH = 3; @@ -41,6 +42,12 @@ export class TokenIdPath extends Struct({ export interface XYKConfig { feeDivider: bigint; fee: bigint; + // Volume fee, maximum percentage fee for 100% relative volatility + volumeFee: bigint; + + // // Percentage of the pool's token reverses that have to be traded each block for 1 volumeFee to be applied extra (scaled by feeDivider) + // // 100% = 1e9 + // volumeDivider: bigint; } /** @@ -57,7 +64,8 @@ export class XYK extends RuntimeModule { */ public constructor( @inject("Balances") public balances: Balances, - @inject("TokenRegistry") public tokenRegistry: TokenRegistry + @inject("TokenRegistry") public tokenRegistry: TokenRegistry, + @inject("VolumeOracle") public volumeOracle: VolumeOracle, ) { super(); } @@ -81,7 +89,7 @@ export class XYK extends RuntimeModule { tokenAId: TokenId, tokenBId: TokenId, tokenAAmount: Balance, - tokenBAmount: Balance + tokenBAmount: Balance, ) { const tokenPair = TokenPair.from(tokenAId, tokenBId); const poolKey = PoolKey.fromTokenPair(tokenPair); @@ -104,15 +112,15 @@ export class XYK extends RuntimeModule { tokenAId.greaterThan(tokenBId), Balance, tokenAAmount, - tokenBAmount - ).value + tokenBAmount, + ).value, ); this.tokenRegistry.addTokenId(lpTokenId); this.balances.mintAndIncrementSupply( lpTokenId, creator, - initialLPTokenSupply + initialLPTokenSupply, ); this.pools.set(poolKey, placeholderPoolValue); } @@ -132,7 +140,7 @@ export class XYK extends RuntimeModule { tokenAId: TokenId, tokenBId: TokenId, tokenAAmount: Balance, - tokenBAmountLimit: Balance + tokenBAmountLimit: Balance, ) { const tokenPair = TokenPair.from(tokenAId, tokenBId); // tokenAId = tokenPair.tokenAId; @@ -145,7 +153,7 @@ export class XYK extends RuntimeModule { const reserveB = this.balances.getBalance(tokenBId, poolKey); const reserveANotZero = reserveA.greaterThan(Balance.from(0)); const adjustedReserveA = Balance.from( - Provable.if(reserveANotZero, reserveA.value, Balance.from(1).value) + Provable.if(reserveANotZero, reserveA.value, Balance.from(1).value), ); // TODO: why do i need Balance.from on the `amountA` argument??? @@ -181,7 +189,7 @@ export class XYK extends RuntimeModule { lpTokenAmount: Balance, // TODO: change to min/max limits everywhere tokenAAmountLimit: Balance, - tokenBLAmountLimit: Balance + tokenBLAmountLimit: Balance, ) { const tokenPair = TokenPair.from(tokenAId, tokenBId); tokenAId = tokenPair.tokenAId; @@ -195,8 +203,8 @@ export class XYK extends RuntimeModule { Provable.if( lpTokenTotalSupplyIsZero, Balance.from(1).value, - lpTokenTotalSupply.value - ) + lpTokenTotalSupply.value, + ), ); const reserveA = this.balances.getBalance(tokenAId, poolKey); const reserveB = this.balances.getBalance(tokenBId, poolKey); @@ -233,7 +241,7 @@ export class XYK extends RuntimeModule { public calculateTokenOutAmountFromReserves( reserveIn: Balance, reserveOut: Balance, - amountIn: Balance + amountIn: Balance, ) { const numerator = amountIn.mul(reserveOut); const denominator = reserveIn.add(amountIn); @@ -241,7 +249,7 @@ export class XYK extends RuntimeModule { // TODO: extract to safemath const adjustedDenominator = Balance.from( Provable.if(denominator.equals(0), Balance, Balance.from(1), denominator) - .value + .value, ); assert(denominator.equals(adjustedDenominator), "denominator is zero"); @@ -252,7 +260,7 @@ export class XYK extends RuntimeModule { public calculateTokenOutAmount( tokenIn: TokenId, tokenOut: TokenId, - amountIn: Balance + amountIn: Balance, ) { const tokenPair = TokenPair.from(tokenIn, tokenOut); const pool = PoolKey.fromTokenPair(tokenPair); @@ -263,14 +271,14 @@ export class XYK extends RuntimeModule { return this.calculateTokenOutAmountFromReserves( reserveIn, reserveOut, - amountIn + amountIn, ); } public calculateAmountIn( tokenIn: TokenId, tokenOut: TokenId, - amountOut: Balance + amountOut: Balance, ) { const tokenPair = TokenPair.from(tokenIn, tokenOut); const pool = PoolKey.fromTokenPair(tokenPair); @@ -284,7 +292,7 @@ export class XYK extends RuntimeModule { public calculateAmountInFromReserves( reserveIn: Balance, reserveOut: Balance, - amountOut: Balance + amountOut: Balance, ) { const numerator = reserveIn.mul(amountOut); const denominator = reserveOut.sub(amountOut); @@ -292,7 +300,7 @@ export class XYK extends RuntimeModule { // TODO: extract to safemath const adjustedDenominator = Balance.from( Provable.if(denominator.equals(0), Balance, Balance.from(1), denominator) - .value + .value, ); assert(denominator.equals(adjustedDenominator), "denominator is zero"); @@ -304,7 +312,7 @@ export class XYK extends RuntimeModule { seller: PublicKey, { path }: TokenIdPath, amountIn: Balance, - amountOutMinLimit: Balance + amountOutMinLimit: Balance, ) { const initialTokenPair = TokenPair.from(path[0], path[1]); const initialPoolKey = PoolKey.fromTokenPair(initialTokenPair); @@ -331,23 +339,61 @@ export class XYK extends RuntimeModule { const calculatedAmountOut = this.calculateTokenOutAmount( tokenIn, tokenOut, - Balance.from(amountIn) + Balance.from(amountIn), ); - const amoutOutWithoutFee = calculatedAmountOut.sub( - calculatedAmountOut.mul(3n).div(100000n) + this.volumeOracle.addVolume( + poolKey, + tokenPair.tokenAId.equals(tokenIn), + calculatedAmountOut, + ); + + const { fee, volumeFee, feeDivider } = this.config; + + // Volatility-based fee calculation + const averageVolatility = UInt64.from( + this.volumeOracle.getAverage(poolKey, 3).magnitude, + ); + const reservesTokenA = this.balances.getBalance( + tokenPair.tokenAId, + poolKey, ); + const reservesTokenASafe = Provable.if( + reservesTokenA.equals(0), + Balance, + Balance.from(1), + reservesTokenA, + ); + assert( + reservesTokenASafe.value + .equals(reservesTokenA.value) + // For the case that this loop run is a dummy + .or(tokenPair.tokenAId.equals(MAX_TOKEN_ID)), + "Reserves empty", + ); + + // Volatility percentage [1e9, 0] + const relativeVolatility = averageVolatility + .mul(1e9) + .div(new Balance(reservesTokenASafe.value)); + // Fee base percentage, represented as fee scaled by feeDivider + const volatilityFeeBase = relativeVolatility.mul(volumeFee).div(1e9); + + const amountOutWithoutFee = calculatedAmountOut + .sub(calculatedAmountOut.mul(fee).div(feeDivider)) + .sub(calculatedAmountOut.mul(volatilityFeeBase).div(feeDivider)); + lastTokenOut = Provable.if(poolExists, TokenId, tokenOut, lastTokenOut); lastPoolKey = Provable.if(poolExists, PoolKey, poolKey, lastPoolKey); amountOut = Balance.from( - Provable.if(poolExists, Balance, amoutOutWithoutFee, amountOut).value + Provable.if(poolExists, Balance, amountOutWithoutFee, amountOut).value, ); amountIn = UInt64.from( - Provable.if(poolExists, Balance, amountIn, Balance.zero).value + Provable.if(poolExists, Balance, amountIn, Balance.zero).value, ); this.balances.transfer(tokenIn, sender, lastPoolKey, amountIn); @@ -369,7 +415,7 @@ export class XYK extends RuntimeModule { tokenAId: TokenId, tokenBId: TokenId, tokenAAmount: Balance, - tokenBAmount: Balance + tokenBAmount: Balance, ) { const creator = this.transaction.sender.value; this.createPool(creator, tokenAId, tokenBId, tokenAAmount, tokenBAmount); @@ -380,7 +426,7 @@ export class XYK extends RuntimeModule { tokenAId: TokenId, tokenBId: TokenId, tokenAAmount: Balance, - tokenBAmountLimit: Balance + tokenBAmountLimit: Balance, ) { const provider = this.transaction.sender.value; this.addLiquidity( @@ -388,7 +434,7 @@ export class XYK extends RuntimeModule { tokenAId, tokenBId, tokenAAmount, - tokenBAmountLimit + tokenBAmountLimit, ); } @@ -398,7 +444,7 @@ export class XYK extends RuntimeModule { tokenBId: TokenId, lpTokenAmount: Balance, tokenAAmountLimit: Balance, - tokenBLAmountLimit: Balance + tokenBLAmountLimit: Balance, ) { const provider = this.transaction.sender.value; this.removeLiquidity( @@ -407,7 +453,7 @@ export class XYK extends RuntimeModule { tokenBId, lpTokenAmount, tokenAAmountLimit, - tokenBLAmountLimit + tokenBLAmountLimit, ); } @@ -415,13 +461,13 @@ export class XYK extends RuntimeModule { public sellPathSigned( path: TokenIdPath, amountIn: Balance, - amountOutMinLimit: Balance + amountOutMinLimit: Balance, ) { this.sellPath( this.transaction.sender.value, path, amountIn, - amountOutMinLimit + amountOutMinLimit, ); } } diff --git a/packages/chain/test/runtime/xyk/xyk.test.ts b/packages/chain/test/runtime/xyk/xyk.test.ts index 90984e8..bb8d6d4 100644 --- a/packages/chain/test/runtime/xyk/xyk.test.ts +++ b/packages/chain/test/runtime/xyk/xyk.test.ts @@ -398,7 +398,7 @@ describe("xyk", () => { }); }); - describe("sell", () => { + describe.only("sell", () => { beforeAll(async () => { nonce = 0; appChain = fromRuntime(modules); @@ -451,7 +451,7 @@ describe("xyk", () => { appChain, alicePrivateKey, path, - Balance.from(100), + Balance.from(10000), Balance.from(1), { nonce: nonce++ } ); @@ -471,8 +471,8 @@ describe("xyk", () => { alice ); - expect(balanceA?.toString()).toEqual("999900"); - expect(balanceB?.toString()).toEqual("1000099"); + expect(balanceA?.toString()).toEqual("990000"); + expect(balanceB?.toString()).toEqual((1009871 - 39).toString()); Provable.log("balances", { balanceA,