Skip to content

Commit ef7dfba

Browse files
authored
feat: xcp_profit up-only (#131)
* feat: xcp_profit up-only * test: rm xcpx from stateful tests
1 parent 7fe87c3 commit ef7dfba

8 files changed

Lines changed: 100 additions & 47 deletions

File tree

contracts/main/Twocrypto.vy

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ balances: public(uint256[N_COINS])
206206
D: public(uint256)
207207
xcp_profit: public(uint256)
208208
xcp_profit_a: public(uint256) # <--- Full profit at last claim of admin fees.
209+
admin_claimed_profit: public(uint256) # Cumulative admin extraction from LP+DAO bucket, in vp units.
209210

210211
virtual_price: public(uint256) # <------ Cached (fast to read) virtual price.
211212
# The cached `virtual_price` is also used internally.
@@ -680,6 +681,7 @@ def add_liquidity(
680681
self.virtual_price = 10**18
681682
self.xcp_profit = 10**18
682683
self.xcp_profit_a = 10**18
684+
self.admin_claimed_profit = 0
683685

684686
self.mint(receiver, d_token)
685687
assert d_token >= min_mint_amount, "slippage"
@@ -1152,9 +1154,21 @@ def tweak_price(
11521154
# 1. xcp_profit grows after virtual price, total growth since launch = (xcp_profit − 1)
11531155
# 2. We reserve lp_profit_fraction of the growth for LPs and admin, rest is used to rebalance the pool
11541156

1155-
# Rebalancing condition transformation:
1156-
# virtual_price > 1 + (xcp_profit - 1) * lp_profit_fraction
1157-
threshold_vp: uint256 = PRECISION + (max(xcp_profit, PRECISION) - PRECISION) * self.lp_profit_fraction // FEE_PRECISION
1157+
# Rebalancing condition basis:
1158+
# virtual_price > 1 + (xcp_profit - 1) * lp_profit_fraction - admin_claimed_profit
1159+
# | pre_admin_threshold_vp |
1160+
# Interpretation:
1161+
# 1. xcp_profit - 1 is total gross profit growth above baseline.
1162+
# 2. Multiplying by lp_profit_fraction keeps only the LP+DAO retained share.
1163+
# 3. admin_claimed_profit subtracts what the admin has already extracted from that bucket.
1164+
# 4. The threshold is floored at PRECISION.
1165+
pre_admin_threshold_vp: uint256 = PRECISION + (
1166+
unsafe_sub(max(xcp_profit, PRECISION), PRECISION) * self.lp_profit_fraction // FEE_PRECISION
1167+
)
1168+
threshold_vp: uint256 = max(
1169+
PRECISION,
1170+
pre_admin_threshold_vp - min(self.admin_claimed_profit, pre_admin_threshold_vp),
1171+
)
11581172
# user_supply < total_supply => vp_boosted > virtual_price
11591173
# by not accounting for donation shares, virtual_price is boosted leading to rebalance trigger
11601174
# this is approximate condition that preliminary indicates readiness for rebalancing
@@ -1370,34 +1384,22 @@ def _claim_admin_fees():
13701384
)
13711385

13721386
if fees > 0:
1373-
# -------------------- Recalculate virtual price and xcp_profit ----------
1387+
# ---------------- Recalculate virtual price and admin claim offset -------
13741388
# We withdraw token balances without touching LP shares, so virtual price goes down.
13751389
updated_vprice: uint256 = current_vprice - fees
13761390
# Do not claim fees if doing so causes virtual price to drop below 10**18.
13771391
if updated_vprice < 10**18:
13781392
return
1379-
# To maintain rebalancing condition vp' > 1 + (xcp_profit' - 1)*lpf:
1380-
# (i.e. not to affect rebalancing invariant)
1381-
# at the boundary:
1382-
# vp - f = 1 + (xcp_profit - 1)*lpf - f
1383-
# vp - f = 1 + (xcp_profit - 1 - f/lpf)*lpf
1384-
# => xcp_profit' := xcp_profit - f / lpf
1385-
xcp_profit -= fees * FEE_PRECISION // lp_profit_fraction # (fees>0 => lpf>0)
1386-
1387-
# Another way to see this is to track total_admin_claimed (sum_i(fees)), the cumulative amount
1388-
# extracted from the LP+DAO bucket, and compare virtual_price against
1389-
# 1 + (xcp_profit - 1) * lpf - total_admin_claimed instead.
1390-
# This alternative model would keep xcp_profit strictly increasing.
1391-
# Instead we fold claimed profit directly into xcp_profit here.
1392-
1393-
# xcp_profit as raw value is thus shouldn't be used in integrations!
1393+
# Keep xcp_profit as the gross profit signal and track claimed admin
1394+
# extraction separately in the same virtual-price units as `fees`.
1395+
# Rebalancing thresholding then compares virtual_price against:
1396+
# 1 + (xcp_profit - 1) * lpf - admin_claimed_profit
1397+
self.admin_claimed_profit += fees
13941398

13951399
# ---------------------------- Update State ------------------------------
13961400
self.virtual_price = updated_vprice
1397-
self.xcp_profit = xcp_profit
13981401
self.last_admin_fee_claim_timestamp = block.timestamp
1399-
if xcp_profit > xcp_profit_a:
1400-
self.xcp_profit_a = xcp_profit # <-------- Cache last claimed profit.
1402+
self.xcp_profit_a = xcp_profit # <-------- Cache last claimed gross profit.
14011403

14021404
# Adjust D after admin removes liquidity
14031405
# no _get_D() because we can't claim during ramping

tests/stateful/stateful_base.py

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ class StatefulBase(RuleBasedStateMachine):
2525
decimals = None
2626
xcp_profit = 0
2727
xcp_profit_a = 0
28-
xcpx = 0
2928
depositors = None
3029
equilibrium = 0
3130
swapped_once = False
@@ -80,7 +79,6 @@ def donations_as_user():
8079
# initial profit is 1e18
8180
self.xcp_profit = 1e18
8281
self.xcp_profit_a = 1e18
83-
self.xcpx = 1e18
8482

8583
self.depositors = set()
8684

@@ -605,28 +603,12 @@ def virtual_price(self):
605603

606604
@invariant()
607605
def up_only_profit(self):
608-
"""This method checks if the pool is profitable, since it should
609-
never lose money.
610-
611-
To do so we use the so called `xcpx`. This is an empirical measure
612-
of profit that is even stronger than `xcp`. We have to use this
613-
because `xcp` goes down when claiming admin fees.
614-
615-
You can imagine `xcpx` as a value that that is always between the
616-
interval [xcp_profit, xcp_profit_a]. When `xcp` goes down
617-
when claiming fees, `xcp_a` goes up. Averaging them creates this
618-
measure of profit that only goes down when something went wrong.
619-
"""
606+
"""Profit should be monotone outside ramping."""
620607
xcp_profit = self.pool.xcp_profit()
621-
xcp_profit_a = self.pool.xcp_profit_a()
622-
xcpx = (xcp_profit + xcp_profit_a + 1e18) // 2
623608

624-
# make sure that the previous profit is smaller than the current
625-
assert xcpx >= self.xcpx, "xcpx has decreased"
626-
# updates the previous profits
627-
self.xcpx = xcpx
609+
assert xcp_profit >= self.xcp_profit, "xcp_profit has decreased"
628610
self.xcp_profit = xcp_profit
629-
self.xcp_profit_a = xcp_profit_a
611+
self.xcp_profit_a = self.pool.xcp_profit_a()
630612

631613

632614
TestBase = StatefulBase.TestCase

tests/stateful/test_stateful.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ def can_always_withdraw(self, imbalanced_operations_allowed=True):
252252
def virtual_price(self):
253253
# we disable this invariant because claiming admin fees can break it.
254254
# claiming admin_fees can lead to a decrease in the virtual price
255-
# however the pool is still profitable as long as xcpx is increasing.
255+
# while xcp_profit remains monotone in the non-ramping model.
256256
pass
257257

258258

tests/unitary/pool/test_claim_admin_fees.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,9 @@ def test_claim_no_rebalancing(gm_pool, fee_receiver):
173173
balance_pool(pool_instance)
174174

175175
pool_values_post = coin0_values(pool_instance, pool_instance.address)
176+
xcp_profit_pre_claim = pool_instance.xcp_profit()
177+
xcp_profit_a_pre_claim = pool_instance.xcp_profit_a()
178+
admin_claimed_profit_pre = pool_instance.admin_claimed_profit()
176179

177180
pool_values_change = [pool_values_post[i] - pool_values_init[i] for i in [0, 1]]
178181
pool_value_surplus = sum(pool_values_change)
@@ -190,10 +193,20 @@ def test_claim_no_rebalancing(gm_pool, fee_receiver):
190193
# claim admin fees
191194
pool_instance.internal._claim_admin_fees()
192195
receiver_values_post = coin0_values(pool_instance, fee_receiver)
196+
expected_fees = (
197+
(xcp_profit_pre_claim - xcp_profit_a_pre_claim)
198+
* pool_instance.lp_profit_fraction()
199+
* pool_instance.admin_fee()
200+
// 10**10
201+
// 10**10
202+
)
193203

194204
value_received = sum(receiver_values_post) - sum(receiver_values_init)
195205
# approx because add_liq doesn't earn for xcp_profit
196206
assert value_received == pytest.approx(estimated_profit_admin, rel=1e-8)
207+
assert pool_instance.xcp_profit() == xcp_profit_pre_claim
208+
assert pool_instance.xcp_profit_a() == xcp_profit_pre_claim
209+
assert pool_instance.admin_claimed_profit() == admin_claimed_profit_pre + expected_fees
197210

198211

199212
def test_n_claim_no_rebalancing(gm_pool, fee_receiver):
@@ -209,11 +222,14 @@ def test_n_claim_no_rebalancing(gm_pool, fee_receiver):
209222
assert pool_instance.coins[0].balanceOf(fee_receiver) == 0
210223
assert pool_instance.coins[1].balanceOf(fee_receiver) == 0
211224

225+
expected_admin_claimed_profit = 0
226+
212227
for _ in range(N_REP):
213228
boa.env.time_travel(seconds=86_400) # so that we can claim repeatedly
214229

215230
pool_values_init = coin0_values(pool_instance, pool_instance.address)
216231
P_init = pool_instance.xcp_profit()
232+
P_a_init = pool_instance.xcp_profit_a()
217233

218234
work_pool(pool_instance, N_TRADES, TRADE_SIZE, update_ema=False)
219235
balance_pool(pool_instance)
@@ -242,10 +258,21 @@ def test_n_claim_no_rebalancing(gm_pool, fee_receiver):
242258
receiver_values_init = coin0_values(pool_instance, fee_receiver)
243259
pool_instance.internal._claim_admin_fees()
244260
receiver_values_post = coin0_values(pool_instance, fee_receiver)
261+
expected_fees = (
262+
(P_post - P_a_init)
263+
* pool_instance.lp_profit_fraction()
264+
* pool_instance.admin_fee()
265+
// 10**10
266+
// 10**10
267+
)
268+
expected_admin_claimed_profit += expected_fees
245269

246270
value_received = sum(receiver_values_post) - sum(receiver_values_init)
247271

248272
assert value_received == pytest.approx(estimated_profit_admin, rel=1e-2)
273+
assert pool_instance.xcp_profit() == P_post
274+
assert pool_instance.xcp_profit_a() == P_post
275+
assert pool_instance.admin_claimed_profit() == expected_admin_claimed_profit
249276

250277

251278
def test_n_claim_lp_no_rebalancing(gm_pool, fee_receiver):

tests/unitary/pool/test_claim_admin_fees_grid.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def test_claim_admin_fees_grid_no_rebalancing(
6262

6363
xcp_profit_pre = pool_instance.xcp_profit()
6464
xcp_profit_a_pre = pool_instance.xcp_profit_a()
65+
admin_claimed_profit_pre = pool_instance.admin_claimed_profit()
6566
virtual_price_pre = pool_instance.virtual_price()
6667
D_pre = pool_instance.D()
6768
balances_pre = pool_instance.balances()
@@ -73,7 +74,6 @@ def test_claim_admin_fees_grid_no_rebalancing(
7374

7475
accrued = xcp_profit_pre - xcp_profit_a_pre
7576
expected_fees = accrued * lp_profit_fraction * admin_fee // FEE_PRECISION // FEE_PRECISION
76-
expected_xcp_drop = expected_fees * FEE_PRECISION // lp_profit_fraction
7777
expected_vp_post = virtual_price_pre - expected_fees
7878
expected_D_post = D_pre - D_pre * expected_fees // virtual_price_pre
7979
expected_admin_amounts = [
@@ -92,8 +92,9 @@ def test_claim_admin_fees_grid_no_rebalancing(
9292
assert actual_admin_amounts == expected_admin_amounts
9393

9494
assert pool_instance.virtual_price() == expected_vp_post
95-
assert pool_instance.xcp_profit() == xcp_profit_pre - expected_xcp_drop
96-
assert pool_instance.xcp_profit_a() == xcp_profit_pre - expected_xcp_drop
95+
assert pool_instance.xcp_profit() == xcp_profit_pre
96+
assert pool_instance.xcp_profit_a() == xcp_profit_pre
97+
assert pool_instance.admin_claimed_profit() == admin_claimed_profit_pre + expected_fees
9798
assert pool_instance.D() == expected_D_post
9899

99100
receiver_value_post = sum(coin0_values(pool_instance, fee_receiver))

tests/unitary/pool/test_rebalance_compounding.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def _snapshot(pool_instance):
5353
return {
5454
"virtual_price": pool_instance.virtual_price(),
5555
"xcp_profit": pool_instance.xcp_profit(),
56+
"admin_claimed_profit": pool_instance.admin_claimed_profit(),
5657
"get_virtual_price": pool_instance.get_virtual_price(),
5758
"price_scale": pool_instance.price_scale(),
5859
"price_oracle": pool_instance.price_oracle(),
@@ -113,6 +114,27 @@ def _run_rebalance_probe(pool_instance):
113114
return rebalance_count, price_scale_path
114115

115116

117+
def _inject_threshold_probe(pool_instance):
118+
pool_instance.inject_function(
119+
"""
120+
@external
121+
@view
122+
def threshold_probe() -> uint256:
123+
threshold_base: uint256 = (
124+
10**18
125+
+ (max(self.xcp_profit, 10**18) - 10**18) * self.lp_profit_fraction // 10**10
126+
)
127+
threshold_vp: uint256 = threshold_base
128+
if threshold_base > 10**18:
129+
threshold_vp = unsafe_sub(
130+
threshold_base,
131+
min(self.admin_claimed_profit, unsafe_sub(threshold_base, 10**18)),
132+
)
133+
return threshold_vp
134+
"""
135+
)
136+
137+
116138
def test_synthetic_vp_xcp_state_is_coherent(pool, factory_admin):
117139
with boa.env.anchor():
118140
boa.env.enable_fast_mode()
@@ -202,3 +224,20 @@ def test_same_block_noop_does_not_consume_rebalance_slot(pool, factory_admin):
202224
)
203225

204226
assert pool_instance.price_scale() != price_scale_before
227+
228+
229+
def test_admin_claimed_profit_offsets_threshold_vp_with_floor(pool):
230+
with boa.env.anchor():
231+
pool_instance = GodModePool(pool)
232+
pool_instance.add_liquidity_balanced(INITIAL_LIQ)
233+
_inject_threshold_probe(pool_instance)
234+
235+
pool_instance.eval("self.xcp_profit = 3 * 10**18")
236+
pool_instance.eval("self.admin_claimed_profit = 0")
237+
assert pool_instance.instance.inject.threshold_probe() == 2 * PRECISION
238+
239+
pool_instance.eval("self.admin_claimed_profit = 3 * 10**17")
240+
assert pool_instance.instance.inject.threshold_probe() == 17 * PRECISION // 10
241+
242+
pool_instance.eval("self.admin_claimed_profit = 5 * 10**18")
243+
assert pool_instance.instance.inject.threshold_probe() == PRECISION

tests/unitary/twocrypto/test_policy_parity.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ def _state_snapshot(pool):
2121
"virtual_price": pool.virtual_price(),
2222
"xcp_profit": pool.xcp_profit(),
2323
"xcp_profit_a": pool.xcp_profit_a(),
24+
"admin_claimed_profit": pool.admin_claimed_profit(),
2425
}
2526

2627

tests/utils/god_mode.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ def get_metrics_snapshot(self):
133133
"virtual_price": self.instance.virtual_price(),
134134
"xcp_profit": self.instance.xcp_profit(),
135135
"xcp_profit_a": self.instance.xcp_profit_a(),
136+
"admin_claimed_profit": self.instance.admin_claimed_profit(),
136137
"price_scale": self.instance.price_scale(),
137138
"price_oracle": self.instance.price_oracle(),
138139
"total_supply": self.instance.totalSupply(),

0 commit comments

Comments
 (0)