dxod repo init

This commit is contained in:
tim
2025-09-15 14:21:56 -04:00
commit 5fb2b17b2e
18 changed files with 4996 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
# Compiler files
cache/
out/
docs/
log/
.env
.idea
# Ignores development broadcast logs
!/broadcast
/broadcast/*/31337/
/broadcast/**/dry-run/

9
.gitmodules vendored Normal file
View File

@@ -0,0 +1,9 @@
[submodule "lib/forge-std"]
path = lib/forge-std
url = https://github.com/foundry-rs/forge-std
[submodule "lib/abdk-libraries-solidity"]
path = lib/abdk-libraries-solidity
url = https://github.com/abdk-consulting/abdk-libraries-solidity
[submodule "lib/openzeppelin-contracts"]
path = lib/openzeppelin-contracts
url = https://github.com/OpenZeppelin/openzeppelin-contracts

49
bin/mock Executable file
View File

@@ -0,0 +1,49 @@
#!/bin/bash
# Function to cleanup processes
cleanup() {
kill $ANVIL_PID 2>/dev/null
}
# Set up trap to handle script exit
trap cleanup EXIT
# Create log directory if it doesn't exist
mkdir -p log
# Run anvil in background and redirect output to log file
anvil | tee log/anvil.txt &
ANVIL_PID=$!
# Function to check if string exists in file
check_string() {
grep -q "$1" "$2"
return $?
}
# Wait for anvil to start (max 30 seconds)
echo "Waiting for anvil to start..."
counter=0
while ! check_string "Listening on" "log/anvil.txt"; do
sleep 1
counter=$((counter + 1))
if [ $counter -ge 5 ]; then
echo "Timeout waiting for anvil to start"
exit 1
fi
done
# Extract bytecode using jq
BYTECODE=$(jq -r '.bytecode.object' out/PartyPool.sol/PartyPool.json)
if [ $? -ne 0 ] || [ -z "$BYTECODE" ]; then
echo "Failed to extract bytecode from PartyPool.json"
exit 1
fi
export BYTECODE
forge script DeployMock --broadcast
echo "Press Ctrl+C to exit..."
while true; do
sleep 1
done

47
doc/stablecoins.csv Normal file
View File

@@ -0,0 +1,47 @@
Token Name,Token Symbol,Price,Volume (24h)
Tether,USDT,1.00,124178611175
USDC,USDC,0.9996,15873101191
First Digital USD,FDUSD,0.9981,6001738498
World Liberty Financial USD,USD1,0.9994,443670921
Ethena USDe,USDe,1.00,203389879
Dai,DAI,0.9998,107786336
Ripple USD,RLUSD,0.9997,95900714
PayPal USD,PYUSD,0.9994,84926338
Falcon USD,USDf,1.00,75128301
EURC,EURC,1.17,50655783
TrueUSD,TUSD,0.9976,41243610
StabiR USD,USDR,0.9980,37757919
EUR CoinVertible,EURCV,1.17,36001785
StraitsX USD,XUSD,1.00,30768737
Global Dollar,USDG,1.00,26395021
AUSD,AUSD,0.9999,23425385
Quantoz EURQ,EURQ,1.17,15986967
Quantoz USDQ,USDQ,0.9993,11283404
Elixir deUSD,DEUSD,0.9992,11801718
Gemini Dollar,GUSD,0.9997,10831936
Eurite,EURi,1.17,8463732
USDD,USDD,0.9998,6457661
BUSD,BUSD,0.9998,4320760
Ondo US Dollar Yield,USDY,1.09,3876681
JUSDJ,JUSDJ,1.32,3243537
Pax Dollar,USDP,0.9998,3010075
StabiR Euro,EURR,1.16,2581109
Steem Dollars,SBD,0.8492,2517238
Bucket Protocol BUCK Stablecoin,BUCK,0.9990,2375173
Hyper USD,USDHL,0.9995,2227470
USDP Stablecoin,USDP,0.9998,2126519
AllUnity EUR,EURAU,1.17,1919081
Celo Dollar,CUSD,1.00,1809631
Worldwide USD,WUSD,1.00,1306544
Usual USD,USD0,0.9981,3897930
GHO,GHO,0.9997,675336
Legacy Frax Dollar,FRAX,0.9979,513159
STASIS EURO,EURS,1.16,23449869
Noble Dollar,USDN,0.9996,
Frax USD,FRXUSD,0.9986,
lisUSD,lisUSD,0.9990,34118
USDB,USDB,0.9987,952802
Venus BUSD,vBUSD,0.02229,
MNEE,MNEE,0.9974,86346
Anchored Coins,AEUR,1.10,26168
Lift Dollar,USDL,0.9987,76398
1 Token Name Token Symbol Price Volume (24h)
2 Tether USDT 1.00 124178611175
3 USDC USDC 0.9996 15873101191
4 First Digital USD FDUSD 0.9981 6001738498
5 World Liberty Financial USD USD1 0.9994 443670921
6 Ethena USDe USDe 1.00 203389879
7 Dai DAI 0.9998 107786336
8 Ripple USD RLUSD 0.9997 95900714
9 PayPal USD PYUSD 0.9994 84926338
10 Falcon USD USDf 1.00 75128301
11 EURC EURC 1.17 50655783
12 TrueUSD TUSD 0.9976 41243610
13 StabiR USD USDR 0.9980 37757919
14 EUR CoinVertible EURCV 1.17 36001785
15 StraitsX USD XUSD 1.00 30768737
16 Global Dollar USDG 1.00 26395021
17 AUSD AUSD 0.9999 23425385
18 Quantoz EURQ EURQ 1.17 15986967
19 Quantoz USDQ USDQ 0.9993 11283404
20 Elixir deUSD DEUSD 0.9992 11801718
21 Gemini Dollar GUSD 0.9997 10831936
22 Eurite EURi 1.17 8463732
23 USDD USDD 0.9998 6457661
24 BUSD BUSD 0.9998 4320760
25 Ondo US Dollar Yield USDY 1.09 3876681
26 JUSDJ JUSDJ 1.32 3243537
27 Pax Dollar USDP 0.9998 3010075
28 StabiR Euro EURR 1.16 2581109
29 Steem Dollars SBD 0.8492 2517238
30 Bucket Protocol BUCK Stablecoin BUCK 0.9990 2375173
31 Hyper USD USDHL 0.9995 2227470
32 USDP Stablecoin USDP 0.9998 2126519
33 AllUnity EUR EURAU 1.17 1919081
34 Celo Dollar CUSD 1.00 1809631
35 Worldwide USD WUSD 1.00 1306544
36 Usual USD USD0 0.9981 3897930
37 GHO GHO 0.9997 675336
38 Legacy Frax Dollar FRAX 0.9979 513159
39 STASIS EURO EURS 1.16 23449869
40 Noble Dollar USDN 0.9996
41 Frax USD FRXUSD 0.9986
42 lisUSD lisUSD 0.9990 34118
43 USDB USDB 0.9987 952802
44 Venus BUSD vBUSD 0.02229
45 MNEE MNEE 0.9974 86346
46 Anchored Coins AEUR 1.10 26168
47 Lift Dollar USDL 0.9987 76398

17
foundry.toml Normal file
View File

@@ -0,0 +1,17 @@
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
remappings = [
'@openzeppelin/=lib/openzeppelin-contracts/',
'@abdk/=lib/abdk-libraries-solidity/',
]
optimizer=true
optimizer_runs=999999999
viaIR=true
gas_reports = ['PartyPool']
[lint]
exclude_lints=['mixed-case-variable', 'unaliased-plain-import', ]
# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options

1
lib/forge-std Submodule

Submodule lib/forge-std added at 8bbcf6e3f8

View File

@@ -0,0 +1,354 @@
# lmsr_vs_cp_sim.py
# Requires: Python 3.9+, numpy, matplotlib
# Optional: seaborn (for prettier plots)
import math
import numpy as np
import matplotlib.pyplot as plt
try:
import seaborn as sns
sns.set_context("talk")
sns.set_style("whitegrid")
except Exception:
pass
# ---------------------------
# Core AMM primitives
# ---------------------------
def cp_price(X, Y):
# Instantaneous marginal price p = dy/dx for constant product
return Y / X
def cp_trade_y_out(X, Y, qx_in, fee=0.0):
"""
Swap x-in (amount qx_in) for y-out on a constant-product pool with fee.
Fee taken on input (Uniswap-style): only (1-fee)*qx_in enters the invariant.
Returns (dy_out, X_new, Y_new, fee_value).
"""
assert X > 0 and Y > 0 and qx_in >= 0 and 0 <= fee < 1
k = X * Y
effective_in = qx_in * (1 - fee)
X_new = X + effective_in
Y_new = k / X_new
dy_out = Y - Y_new
fee_value = qx_in * fee # in x-units
return dy_out, X_new, Y_new, fee_value
def cp_avg_y_per_x(X, Y, qx_in, fee=0.0):
if qx_in == 0:
return cp_price(X, Y)
dy, *_ = cp_trade_y_out(X, Y, qx_in, fee)
return dy / qx_in
def lmsr_calibrate_b(X, Y, mode="thin_side", factor=0.5):
"""
Calibrate LMSR 'b' to match CP local log-price stiffness at the current state.
- thin_side: use min(X, Y) * factor where factor≈0.5 matches ds/dq at that side
- x_side: use X * factor
- y_side: use Y * factor (useful if measuring q in y-space)
For our use (q measured in x), 'x_side' or 'thin_side' with factor=0.5 are typical.
"""
if mode == "thin_side":
return max(1e-12, factor * min(X, Y))
elif mode == "x_side":
return max(1e-12, factor * X)
elif mode == "y_side":
return max(1e-12, factor * Y)
else:
raise ValueError("mode must be one of ['thin_side','x_side','y_side']")
def lmsr_y_out(qx_in, p0, b, fee=0.0):
"""
Symmetric 2-asset LMSR approximation in x-measure:
- We model log-price s moving linearly in q_x: ds/dq_x = 1/b
- Then price path p(q) = p0 * exp(q/b)
- Cumulative y received for x-in q is integral of p(q) dq: y(q) = p0 * b * (exp(q/b) - 1)
Fee applied to input reduces effective q: q_eff = q * (1 - fee)
Returns dy_out (in y-units) and fee_value (in x-units)
"""
assert qx_in >= 0 and b > 0 and p0 > 0 and 0 <= fee < 1
q_eff = qx_in * (1 - fee)
dy = p0 * b * (math.exp(q_eff / b) - 1.0)
fee_value = qx_in * fee
return dy, fee_value
def lmsr_avg_y_per_x(qx_in, p0, b, fee=0.0):
if qx_in == 0:
return p0
dy, _ = lmsr_y_out(qx_in, p0, b, fee)
return dy / qx_in
# ---------------------------
# Static comparison utilities
# ---------------------------
def static_compare(X, Y, fee_cp=0.0005, fee_lmsr=0.0005, b=None, b_mode="x_side", b_factor=0.5,
q_grid=None):
"""
Compute average execution (y per x), penalties vs spot, and welfare gaps over a grid of trade sizes.
Returns dict with arrays.
"""
p0 = cp_price(X, Y)
if b is None:
b = lmsr_calibrate_b(X, Y, mode=b_mode, factor=b_factor)
if q_grid is None:
# span from tiny to a meaningful fraction of X
q_grid = np.geomspace(1e-9 * X, 0.3 * X, 60)
avg_cp = np.array([cp_avg_y_per_x(X, Y, q, fee_cp) for q in q_grid])
avg_lm = np.array([lmsr_avg_y_per_x(q, p0, b, fee_lmsr) for q in q_grid])
# Slippage penalty vs spot p0 (in y-per-x)
pen_cp = p0 - avg_cp
pen_lm = p0 - avg_lm
# Taker welfare difference: LMSR minus CP (positive means LMSR better for taker)
welfare_gap = avg_lm - avg_cp
return {
"q_grid": q_grid,
"avg_cp": avg_cp,
"avg_lm": avg_lm,
"pen_cp": pen_cp,
"pen_lm": pen_lm,
"welfare_gap": welfare_gap,
"p0": p0,
"b": b
}
# ---------------------------
# Imbalance sweep
# ---------------------------
def sweep_imbalance(X, rho_grid, fee_cp=0.0005, fee_lmsr=0.0005, q_frac=0.01,
b_mode="thin_side", b_factor=0.5):
"""
For a fixed X (x-reserve), sweep imbalance rho = Y/X and measure welfare advantage at a chosen trade size q = q_frac * X.
Returns arrays over rho.
"""
q = q_frac * X
wgap = []
pen_cp_list, pen_lm_list, p0_list, b_list = [], [], [], []
for rho in rho_grid:
Y = rho * X
res = static_compare(X, Y, fee_cp, fee_lmsr, b=None, b_mode=b_mode, b_factor=b_factor, q_grid=np.array([q]))
wgap.append(res["welfare_gap"][0])
pen_cp_list.append(res["pen_cp"][0])
pen_lm_list.append(res["pen_lm"][0])
p0_list.append(res["p0"])
b_list.append(res["b"])
return {
"rho_grid": rho_grid,
"q": q,
"welfare_gap": np.array(wgap),
"pen_cp": np.array(pen_cp_list),
"pen_lm": np.array(pen_lm_list),
"p0": np.array(p0_list),
"b": np.array(b_list)
}
# ---------------------------
# Sequential Monte Carlo simulation
# ---------------------------
def sample_trade_sizes(n, X, dist="lognormal", mean_frac=0.005, std_frac=0.01, seed=None):
"""
Return non-negative trade sizes q in x-units.
- lognormal: parameters chosen so mean ≈ mean_frac*X
- uniform: U(0, 2*mean_frac*X)
"""
rng = np.random.default_rng(seed)
if dist == "lognormal":
mean = mean_frac * X
std = std_frac * X
# Map mean/std to lognormal parameters
# mean = exp(mu + sigma^2/2), var = (exp(sigma^2)-1)exp(2mu+sigma^2)
# Let CV = std/mean => sigma^2 = ln(1+CV^2), mu = ln(mean) - sigma^2/2
cv = std / max(1e-12, mean)
sigma2 = math.log(1 + cv * cv)
sigma = math.sqrt(sigma2)
mu = math.log(max(1e-12, mean)) - sigma2 / 2
q = rng.lognormal(mean=mu, sigma=sigma, size=n)
return q
elif dist == "uniform":
return rng.uniform(0, 2 * mean_frac * X, size=n)
else:
raise ValueError("Unknown dist")
def sequential_sim(X0, Y0, n_trades=2000, direction_bias=0.6,
fee_cp=0.0005, fee_lmsr=0.0005,
b_mode="thin_side", b_factor=0.5,
dist="lognormal", mean_frac=0.005, std_frac=0.01, seed=42):
"""
Run a sequential simulation where each trade either buys y with x (prob=direction_bias)
or buys x with y (prob=1-direction_bias). We compare CP vs LMSR per-trade and accumulate:
- taker surplus difference (y-per-x times q, converted to y-units)
- fee revenue for LPs (x- or y-denominated; we also track value at spot)
- pool state evolution (for CP only; LMSR is state-less under this approximation)
Note: We approximate LMSR as state-less with ds/dq = 1/b, b recalibrated each step to the thin side.
"""
rng = np.random.default_rng(seed)
X_cp, Y_cp = X0, Y0
p_history = []
b_history = []
taker_y_advantage = 0.0
lp_fee_x_cp = 0.0
lp_fee_x_lm = 0.0
# Track realized slippage stats at fixed snapshot prices for comparability
for t in range(n_trades):
p_cp = cp_price(X_cp, Y_cp)
p_history.append(p_cp)
# Calibrate LMSR b to thin side each step
b = lmsr_calibrate_b(X_cp, Y_cp, mode=b_mode, factor=b_factor)
b_history.append(b)
# Decide direction: True => use x to buy y; False => use y to buy x
buy_y = rng.random() < direction_bias
if buy_y:
# Draw trade size in x-units
qx = float(sample_trade_sizes(1, X_cp, dist, mean_frac, std_frac, seed=rng.integers(1e9))[0])
# CP execution
dy_cp, X_cp_new, Y_cp_new, fee_x_cp = cp_trade_y_out(X_cp, Y_cp, qx, fee_cp)
# LMSR execution (approximate with current spot p_cp)
dy_lm, fee_x_lm = lmsr_y_out(qx, p_cp, b, fee_lmsr)
# Update CP state
X_cp, Y_cp = X_cp_new, Y_cp_new
# Accumulate
taker_y_advantage += (dy_lm - dy_cp)
lp_fee_x_cp += fee_x_cp
lp_fee_x_lm += fee_x_lm
else:
# Buy x with y. Mirror the model by symmetry:
# We'll convert problem by swapping roles of (x,y) and using same formulas.
# Draw trade size in y-units proportional to Y
qy = float(sample_trade_sizes(1, Y_cp, dist, mean_frac, std_frac, seed=rng.integers(1e9))[0])
# CP: y-in, x-out
# Symmetric function by swapping X<->Y and interpreting result
dy_dummy, Y_new, X_new, fee_y_cp = cp_trade_y_out(Y_cp, X_cp, qy, fee_cp) # returns x-out in "dy_dummy"
dx_cp = dy_dummy # interpret as x-out
X_cp, Y_cp = X_new, Y_new
# LMSR: state-less approx using ds/dq_y = 1/b_y; we map via price and symmetry.
# For buy-x with y-in, average x per y uses 1/p along exponential path in y-space.
# For simplicity, we mirror by computing y-per-x with LMSR at price p, then invert locally:
# Expected x received ≈ qy / p * (e^{(qy_eff/b_y)/?} - 1)/((qy)/?) ... too detailed.
# Instead, use symmetry by swapping labels and reusing the function with p_inv = 1/p.
p_inv = 1.0 / p_cp
b_y = lmsr_calibrate_b(X_cp, Y_cp, mode="y_side", factor=b_factor)
dx_lm, fee_y_lm = lmsr_y_out(qy, p_inv, b_y, fee_lmsr) # gives x-out
# Taker advantage in x-units; convert to y-units for aggregation using pre-trade spot
taker_y_advantage += (dx_lm - dx_cp) * p_cp # multiply by p to convert x to y at current spot
lp_fee_x_cp += fee_y_cp * p_inv # convert y-fee to x using p_inv
lp_fee_x_lm += fee_y_lm * p_inv
return {
"taker_y_advantage": taker_y_advantage,
"lp_fee_x_cp": lp_fee_x_cp,
"lp_fee_x_lm": lp_fee_x_lm,
"p_history": np.array(p_history),
"b_history": np.array(b_history),
"final_state_cp": (X_cp, Y_cp)
}
# ---------------------------
# Plotting helpers
# ---------------------------
def plot_static(res):
q = res["q_grid"]
p0 = res["p0"]
plt.figure(figsize=(10, 6))
plt.plot(q, res["avg_cp"], label="CP avg y-per-x")
plt.plot(q, res["avg_lm"], label="LMSR avg y-per-x")
plt.axhline(p0, color="gray", linestyle="--", alpha=0.6, label="Spot p0")
plt.xscale("log")
plt.title("Average execution (y per x) vs trade size")
plt.xlabel("q_x (trade size)")
plt.ylabel("y per x")
plt.legend()
plt.tight_layout()
plt.figure(figsize=(10, 6))
plt.plot(q, res["welfare_gap"] / res["p0"] * 1e4)
plt.xscale("log")
plt.axhline(0, color="gray", linestyle="--")
plt.title("Taker welfare advantage LMSRCP (in bp of price)")
plt.xlabel("q_x (trade size)")
plt.ylabel("bp")
plt.tight_layout()
def plot_imbalance(sweep):
rho = sweep["rho_grid"]
plt.figure(figsize=(10, 6))
plt.plot(rho, sweep["welfare_gap"] / sweep["p0"] * 1e4)
plt.axhline(0, color="gray", linestyle="--")
plt.title(f"LMSRCP taker advantage vs imbalance (q={sweep['q']:.4g}·X)")
plt.xlabel("rho = Y/X (imbalance)")
plt.ylabel("bp of price")
plt.tight_layout()
# ---------------------------
# Demo main
# ---------------------------
def demo():
# Baseline pool
X, Y = 10_000.0, 10_000.0
fee_cp = 0.0005 # 5 bp
fee_lmsr = 0.0005 # 5 bp
b_mode = "thin_side"
b_factor = 0.5
# Static comparison across trade sizes
res = static_compare(
X, Y, fee_cp=fee_cp, fee_lmsr=fee_lmsr,
b=None, b_mode=b_mode, b_factor=b_factor
)
print(f"Spot p0={res['p0']:.6f}, calibrated b={res['b']:.6f}")
plot_static(res)
# Imbalance sweep
rho_grid = np.linspace(0.2, 5.0, 60) # from thin-y to thin-x
sw = sweep_imbalance(
X, rho_grid, fee_cp=fee_cp, fee_lmsr=fee_lmsr,
q_frac=0.01, b_mode=b_mode, b_factor=b_factor
)
plot_imbalance(sw)
# Sequential simulation
sim = sequential_sim(
X0=X, Y0=Y, n_trades=2000, direction_bias=0.6,
fee_cp=fee_cp, fee_lmsr=fee_lmsr,
b_mode=b_mode, b_factor=b_factor,
dist="lognormal", mean_frac=0.005, std_frac=0.01, seed=7
)
adv_bp_equiv = sim["taker_y_advantage"] / (X + Y / res["p0"]) * 1e4
print(f"Sequential sim taker Y-advantage (absolute): {sim['taker_y_advantage']:.6f} y-units")
print(f"LP fee (CP) in x-units: {sim['lp_fee_x_cp']:.6f}")
print(f"LP fee (LMSR) in x-units: {sim['lp_fee_x_lm']:.6f}")
plt.show()
if __name__ == "__main__":
demo()

71
script/DeployMock.sol Normal file
View File

@@ -0,0 +1,71 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.30;
import "forge-std/Script.sol";
import "forge-std/console2.sol";
import "@abdk/ABDKMath64x64.sol";
import "../test/MockERC20.sol";
import "../src/IPartyPool.sol";
import "../src/PartyPool.sol";
contract DeployMock is Script {
// private key 0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356
address constant devAccount7 = 0x14dC79964da2C08b23698B3D3cc7Ca32193d9955;
function run() public {
vm.startBroadcast(deployer);
// create mock tokens
usxd = new MockERC20('Joke Currency', 'USXD', 6);
fusd = new MockERC20('Fake USD', 'FUSD', 6);
dive = new MockERC20('DAI Virtually Equal', 'DIVE', 18);
string memory name = 'Mock Pool';
string memory symbol = 'MP';
address[] memory tokens = new address[](3);
tokens[0] = address(usxd);
tokens[1] = address(fusd);
tokens[2] = address(dive);
uint256[] memory _bases = new uint256[](3);
_bases[0] = 6;
_bases[1] = 6;
_bases[2] = 18;
int128 _tradeFrac = ABDKMath64x64.divu(1, 10);
int128 _targetSlippage = ABDKMath64x64.divu(1,10000);
uint256 _feePpm = 100;
IPartyPool pool = new PartyPool();
bytes memory args = abi.encode(name, symbol, tokens, _bases, _tradeFrac, _targetSlippage, _feePpm);
bytes memory deployCode = abi.encodePacked(bytecode,args);
vm.etch(pool, deployCode);
console2.log('PartyPool', pool);
// initial mint
mintAll(pool, 10_000);
IPartyPool(pool).mint(deployer, deployer, 0, 0);
console2.log('USXD', address(usxd));
console2.log('FUSD', address(fusd));
console2.log('DIVE', address(dive));
// give tokens to dev7
mintAll(devAccount7, 1_000_000);
vm.stopBroadcast();
}
address constant deployer = address(0x472358699872673459876); // anything
MockERC20 private usxd;
MockERC20 private fusd;
MockERC20 private dive;
function mintAll(address who, uint256 amount) internal {
usxd.mint(who, amount * 1e6);
fusd.mint(who, amount * 1e6);
dive.mint(who, amount * 1e18);
}
}

View File

@@ -0,0 +1,6 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.30;
interface IPartyFlashCallback {
function partyFlashCallback(uint256[] memory loanAmounts, uint256[] memory repaymentAmounts, bytes calldata data) external;
}

149
src/IPartyPool.sol Normal file
View File

@@ -0,0 +1,149 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.30;
import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
/// @title PartyPool - LMSR-backed multi-asset pool with LP ERC20 token
/// @notice Uses LMSRStabilized library; stores per-token uint bases to convert to/from 64.64 fixed point.
/// - Caches qInternal[] (int128 64.64) and cachedUintBalances[] to minimize balanceOf() calls.
/// - swap and swapToLimit mimic core lib; mint/burn call updateForProportionalChange() and manage LP tokens.
interface IPartyPool is IERC20Metadata {
// All int128's are ABDKMath64x64 format
// Events
event Mint(address payer, address indexed receiver, uint256[] amounts, uint256 lpMinted);
event Burn(address payer, address indexed receiver, uint256[] amounts, uint256 lpBurned);
event Swap(
address payer,
address indexed receiver,
address indexed tokenIn,
address indexed tokenOut,
uint256 amountIn,
uint256 amountOut
);
/// @notice Emitted when a single-token swapMint is executed.
/// Records payer/receiver, input token index, gross transfer (net+fee), net input and fee taken.
event SwapMint(
address indexed payer,
address indexed receiver,
uint256 indexed inputTokenIndex,
uint256 grossTransfer, // total tokens transferred (net + fee)
uint256 netInput, // net input credited to swaps (after fee)
uint256 feeTaken // fee taken (ceil)
);
/// @notice Emitted when a burnSwap is executed.
/// Records payer/receiver, target token index and the uint payout sent to the receiver.
event BurnSwap(
address indexed payer,
address indexed receiver,
uint256 indexed targetTokenIndex,
uint256 payoutUint
);
// Immutable pool configuration (public getters)
function tokens(uint256) external view returns (address); // get single token
function numTokens() external view returns (uint256);
function allTokens() external view returns (address[] memory);
function tradeFrac() external view returns (int128); // ABDK 64x64
function targetSlippage() external view returns (int128); // ABDK 64x64
function swapFeePpm() external view returns (uint256);
function tokenAddressToIndexPlusOne(address) external view returns (uint);
// Initialization / Mint / Burn (LP token managed)
/// @notice Calculate the proportional deposit amounts required for a given LP token amount
/// @param lpTokenAmount The amount of LP tokens desired
/// @return depositAmounts Array of token amounts to deposit (rounded up)
function computeMintAmounts(uint256 lpTokenAmount) external view returns (uint256[] memory depositAmounts);
/// @notice Proportional mint (or initial supply if first call).
/// For initial supply: assumes tokens have already been transferred to the pool
/// For subsequent mints: payer must approve tokens beforehand, receiver gets the LP tokens
/// @param payer address that provides the input tokens (ignored for initial deposit)
/// @param receiver address that receives the LP tokens
/// @param lpTokenAmount desired amount of LP tokens to mint (ignored for initial deposit)
/// @param deadline timestamp after which the transaction will revert. Pass 0 to ignore.
function mint(address payer, address receiver, uint256 lpTokenAmount, uint256 deadline) external;
/// @notice Calculate the proportional withdrawal amounts for a given LP token amount
/// @param lpTokenAmount The amount of LP tokens to burn
/// @return withdrawAmounts Array of token amounts to withdraw (rounded down)
function computeBurnAmounts(uint256 lpTokenAmount) external view returns (uint256[] memory withdrawAmounts);
/// @notice Burn LP tokens and withdraw the proportional basket to receiver.
/// Payer must own the LP tokens; withdraw amounts are computed from current proportions.
/// @param payer address that provides the LP tokens to burn
/// @param receiver address that receives the withdrawn tokens
/// @param lpAmount amount of LP tokens to burn (proportional withdrawal)
/// @param deadline timestamp after which the transaction will revert. Pass 0 to ignore.
function burn(address payer, address receiver, uint256 lpAmount, uint256 deadline) external;
// Swaps
function swap(
address payer,
address receiver,
uint256 i,
uint256 j,
uint256 maxAmountIn,
int128 limitPrice,
uint256 deadline
) external returns (uint256 amountIn, uint256 amountOut);
function swapToLimit(
address payer,
address receiver,
uint256 i,
uint256 j,
int128 limitPrice,
uint256 deadline
) external returns (uint256 amountInUsed, uint256 amountOut);
/// @notice Single-token mint: deposit a single token, charge swap-LMSR cost, and mint LP.
/// @param payer who transfers the input token
/// @param receiver who receives the minted LP tokens
/// @param i index of the input token
/// @param maxAmountIn maximum uint token input (inclusive of fee)
/// @param deadline optional deadline
/// @return lpMinted actual LP minted (uint)
function swapMint(
address payer,
address receiver,
uint256 i,
uint256 maxAmountIn,
uint256 deadline
) external returns (uint256 lpMinted);
/// @notice Burn LP tokens then swap the redeemed proportional basket into a single asset `i` and send to receiver.
/// @param payer who burns LP tokens
/// @param receiver who receives the single asset
/// @param lpAmount amount of LP tokens to burn
/// @param i index of target asset to receive
/// @param deadline optional deadline
/// @return amountOutUint uint amount of asset i sent to receiver
function burnSwap(
address payer,
address receiver,
uint256 lpAmount,
uint256 i,
uint256 deadline
) external returns (uint256 amountOutUint);
/// @notice Receive token0 and/or token1 and pay it back, plus a fee, in the callback
/// @dev The caller of this method receives a callback in the form of IPartyFlashCallback#partyFlashCallback
/// @param recipient The address which will receive the token amounts
/// @param amounts The amount of each token to send
/// @param data Any data to be passed through to the callback
function flash(
address recipient,
uint256[] memory amounts,
bytes calldata data
) external;
}

1138
src/LMSRStabilized.sol Normal file

File diff suppressed because it is too large Load Diff

818
src/PartyPool.sol Normal file
View File

@@ -0,0 +1,818 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.30;
import "@abdk/ABDKMath64x64.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "./LMSRStabilized.sol";
import "./IPartyPool.sol";
import "./IPartyFlashCallback.sol";
/// @title PartyPool - LMSR-backed multi-asset pool with LP ERC20 token
/// @notice Uses LMSRStabilized library; stores per-token uint bases to convert to/from 64.64 fixed point.
/// - Caches qInternal[] (int128 64.64) and cachedUintBalances[] to minimize balanceOf() calls.
/// - swap and swapToLimit mimic core lib; mint/burn call updateForProportionalChange() and manage LP tokens.
contract PartyPool is IPartyPool, ERC20, ReentrancyGuard {
using ABDKMath64x64 for int128;
using LMSRStabilized for LMSRStabilized.State;
using SafeERC20 for IERC20;
//
// Immutable pool configuration
//
address[] public tokens; // effectively immutable since there is no interface to change the tokens
function numTokens() external view returns (uint256) { return tokens.length; }
function allTokens() external view returns (address[] memory) { return tokens; }
// NOTE that the slippage target is only exactly achieved in completely balanced pools where all assets are
// priced the same. This target is actually a minimum slippage that the pool imposes on traders, and the actual
// slippage cost can be multiples bigger in practice due to pool inventory imbalances.
int128 public immutable tradeFrac; // slippage target trade size as a fraction of one asset's inventory
int128 public immutable targetSlippage; // target slippage applied to that trade size
// fee in parts-per-million (ppm), taken from inputs before swaps
uint256 public immutable swapFeePpm;
// flash loan fee in parts-per-million (ppm)
uint256 public immutable flashFeePpm;
//
// Internal state
//
LMSRStabilized.State internal lmsr;
// Cached on-chain balances (uint) and internal 64.64 representation
// balance / base = internal
uint256[] internal cachedUintBalances;
uint256[] internal bases; // per-token uint base used to scale token amounts <-> internal
mapping(address=>uint) public tokenAddressToIndexPlusOne; // Uses index+1 so a result of 0 indicates a failed lookup
uint256 public constant LP_SCALE = 1e18; // Scale used to convert LMSR lastTotal (Q64.64) into LP token units (uint)
/// @param name_ LP token name
/// @param symbol_ LP token symbol
/// @param _tokens token addresses (n)
/// @param _bases scaling bases for each token (n) - used when converting to/from internal 64.64 amounts
/// @param _tradeFrac trade fraction in 64.64 fixed-point (as used by LMSR)
/// @param _targetSlippage target slippage in 64.64 fixed-point (as used by LMSR)
/// @param _swapFeePpm fee in parts-per-million, taken from swap input amounts before LMSR calculations
/// @param _flashFeePpm fee in parts-per-million, taken for flash loans
constructor(
string memory name_,
string memory symbol_,
address[] memory _tokens,
uint256[] memory _bases,
int128 _tradeFrac,
int128 _targetSlippage,
uint256 _swapFeePpm,
uint256 _flashFeePpm
) ERC20(name_, symbol_) {
require(_tokens.length > 1, "Pool: need >1 asset");
require(_tokens.length == _bases.length, "Pool: lengths mismatch");
tokens = _tokens;
bases = _bases;
tradeFrac = _tradeFrac;
targetSlippage = _targetSlippage;
require(_swapFeePpm < 1_000_000, "Pool: fee >= ppm");
swapFeePpm = _swapFeePpm;
require(_flashFeePpm < 1_000_000, "Pool: flash fee >= ppm");
flashFeePpm = _flashFeePpm;
uint256 n = _tokens.length;
// Initialize LMSR state nAssets; full init occurs on first mint when quantities are known.
lmsr.nAssets = n;
// Initialize token address to index mapping
for (uint i = 0; i < n;) {
tokenAddressToIndexPlusOne[_tokens[i]] = i + 1;
unchecked {i++;}
}
// Initialize caches to zero
cachedUintBalances = new uint256[](n);
}
/* ----------------------
Initialization / Mint / Burn (LP token managed)
---------------------- */
/// @notice Calculate the proportional deposit amounts required for a given LP token amount
/// @param lpTokenAmount The amount of LP tokens desired
/// @return depositAmounts Array of token amounts to deposit (rounded up)
function computeMintAmounts(uint256 lpTokenAmount) public view returns (uint256[] memory depositAmounts) {
uint256 n = tokens.length;
depositAmounts = new uint256[](n);
// If this is the first mint or pool is empty, return zeros
// For first mint, tokens should already be transferred to the pool
if (totalSupply() == 0 || lmsr.nAssets == 0) {
return depositAmounts; // Return zeros, initial deposit handled differently
}
// Calculate deposit based on current proportions
uint256 totalLpSupply = totalSupply();
// lpTokenAmount / totalLpSupply = depositAmount / currentBalance
// Therefore: depositAmount = (lpTokenAmount * currentBalance) / totalLpSupply
// We round up to protect the pool
for (uint i = 0; i < n; i++) {
uint256 currentBalance = cachedUintBalances[i];
// Calculate with rounding up: (a * b + c - 1) / c
depositAmounts[i] = (lpTokenAmount * currentBalance + totalLpSupply - 1) / totalLpSupply;
}
return depositAmounts;
}
/// @notice Calculate the proportional withdrawal amounts for a given LP token amount
/// @param lpTokenAmount The amount of LP tokens to burn
/// @return withdrawAmounts Array of token amounts to withdraw (rounded down)
function computeBurnAmounts(uint256 lpTokenAmount) external view returns (uint256[] memory withdrawAmounts) {
return _computeBurnAmounts(lpTokenAmount);
}
function _computeBurnAmounts(uint256 lpTokenAmount) internal view returns (uint256[] memory withdrawAmounts) {
uint256 n = tokens.length;
withdrawAmounts = new uint256[](n);
// If supply is zero or pool uninitialized, return zeros
if (totalSupply() == 0 || lmsr.nAssets == 0) {
return withdrawAmounts; // Return zeros, nothing to withdraw
}
// Calculate withdrawal amounts based on current proportions
uint256 totalLpSupply = totalSupply();
// withdrawAmount = floor(lpTokenAmount * currentBalance / totalLpSupply)
for (uint i = 0; i < n; i++) {
uint256 currentBalance = cachedUintBalances[i];
withdrawAmounts[i] = (lpTokenAmount * currentBalance) / totalLpSupply;
}
return withdrawAmounts;
}
/// @notice Proportional mint (or initial supply if first call).
/// For initial supply: assumes tokens have already been transferred to the pool
/// For subsequent mints: payer must approve tokens beforehand, receiver gets the LP tokens
/// @param payer address that provides the input tokens (ignored for initial deposit)
/// @param receiver address that receives the LP tokens
/// @param lpTokenAmount desired amount of LP tokens to mint (ignored for initial deposit)
/// @param deadline timestamp after which the transaction will revert. Pass 0 to ignore.
function mint(address payer, address receiver, uint256 lpTokenAmount, uint256 deadline) external nonReentrant {
require(deadline == 0 || block.timestamp <= deadline, "mint: deadline exceeded");
uint256 n = tokens.length;
// Check if this is initial deposit
bool isInitialDeposit = totalSupply() == 0 || lmsr.nAssets == 0;
require(lpTokenAmount > 0 || isInitialDeposit, "mint: zero LP amount");
// Capture old pool size metric (scaled) by computing from current balances
uint256 oldScaled = 0;
if (!isInitialDeposit) {
int128 oldTotal = _computeSizeMetric(lmsr.qInternal);
oldScaled = ABDKMath64x64.mulu(oldTotal, LP_SCALE);
}
// For non-initial deposits, transfer tokens from payer
uint256[] memory depositAmounts = new uint256[](n);
if (!isInitialDeposit) {
// Calculate required deposit amounts for the desired LP tokens
depositAmounts = computeMintAmounts(lpTokenAmount);
// Transfer in all token amounts
for (uint i = 0; i < n; ) {
if (depositAmounts[i] > 0) {
_safeTransferFrom(tokens[i], payer, address(this), depositAmounts[i]);
}
unchecked { i++; }
}
}
// Update cached balances for all assets
int128[] memory newQInternal = new int128[](n);
for (uint i = 0; i < n; ) {
uint256 bal = IERC20(tokens[i]).balanceOf(address(this));
cachedUintBalances[i] = bal;
newQInternal[i] = _uintToInternalFloor(bal, bases[i]);
// For initial deposit, record the actual deposited amounts
if (isInitialDeposit) {
depositAmounts[i] = bal;
}
unchecked { i++; }
}
// If first time, call init, otherwise update proportional change.
if (isInitialDeposit) {
// Initialize the stabilized LMSR state
lmsr.init(newQInternal, tradeFrac, targetSlippage);
} else {
// Update for proportional change
lmsr.updateForProportionalChange(newQInternal);
}
// Compute actual LP tokens to mint based on change in size metric (scaled)
// floor truncation rounds in favor of the pool
int128 newTotal = _computeSizeMetric(newQInternal);
uint256 newScaled = ABDKMath64x64.mulu(newTotal, LP_SCALE);
uint256 actualLpToMint;
if (isInitialDeposit) {
// Initial provisioning: mint newScaled (as LP units)
actualLpToMint = newScaled;
} else {
require(oldScaled > 0, "mint: oldScaled zero");
uint256 delta = (newScaled > oldScaled) ? (newScaled - oldScaled) : 0;
// Proportional issuance: totalSupply * delta / oldScaled
if (delta > 0) {
// floor truncation rounds in favor of the pool
actualLpToMint = (totalSupply() * delta) / oldScaled;
} else {
actualLpToMint = 0;
}
}
// For subsequent mints, ensure the calculated LP amount is not too different from requested
if (!isInitialDeposit) {
// Allow for some rounding error but ensure we're not far off from requested amount
require(actualLpToMint > 0, "mint: zero LP minted");
// Allow actual amount to be at most 0.00001% less than requested
// This accounts for rounding in deposit calculations
uint256 minAcceptable = lpTokenAmount * 99_999 / 100_000;
require(actualLpToMint >= minAcceptable, "mint: insufficient LP minted");
}
console2.log('actualLpToMint', actualLpToMint);
require( actualLpToMint > 0, "mint: zero LP amount");
_mint(receiver, actualLpToMint);
emit Mint(payer, receiver, depositAmounts, actualLpToMint);
}
/// @notice Burn LP tokens and withdraw the proportional basket to receiver.
/// Payer must own the LP tokens; withdraw amounts are computed from current proportions.
/// @param payer address that provides the LP tokens to burn
/// @param receiver address that receives the withdrawn tokens
/// @param lpAmount amount of LP tokens to burn (proportional withdrawal)
/// @param deadline timestamp after which the transaction will revert. Pass 0 to ignore.
function burn(address payer, address receiver, uint256 lpAmount, uint256 deadline) external nonReentrant {
require(deadline == 0 || block.timestamp <= deadline, "burn: deadline exceeded");
uint256 n = tokens.length;
require(lpAmount > 0, "burn: zero lp");
uint256 supply = totalSupply();
require(supply > 0, "burn: empty supply");
require(lmsr.nAssets > 0, "burn: uninit pool");
require(balanceOf(payer) >= lpAmount, "burn: insufficient LP");
// Refresh cached balances to reflect current on-chain balances before computing withdrawal amounts
for (uint i = 0; i < n; ) {
uint256 bal = IERC20(tokens[i]).balanceOf(address(this));
cachedUintBalances[i] = bal;
unchecked { i++; }
}
// Compute proportional withdrawal amounts for the requested LP amount (rounded down)
uint256[] memory withdrawAmounts = _computeBurnAmounts(lpAmount);
// Transfer underlying tokens out to receiver according to computed proportions
for (uint i = 0; i < n; ) {
if (withdrawAmounts[i] > 0) {
_safeTransfer(tokens[i], receiver, withdrawAmounts[i]);
}
unchecked { i++; }
}
// Update cached balances and internal q for all assets
int128[] memory newQInternal = new int128[](n);
for (uint i = 0; i < n; ) {
uint256 bal = IERC20(tokens[i]).balanceOf(address(this));
cachedUintBalances[i] = bal;
newQInternal[i] = _uintToInternalFloor(bal, bases[i]);
unchecked { i++; }
}
// Apply proportional update or deinitialize if drained
bool allZero = true;
for (uint i = 0; i < n; ) {
if (newQInternal[i] != int128(0)) {
allZero = false;
break;
}
unchecked { i++; }
}
if (allZero) {
lmsr.deinit();
} else {
lmsr.updateForProportionalChange(newQInternal);
}
// Burn exactly the requested LP amount from payer (authorization via allowance)
if (msg.sender != payer) {
uint256 allowed = allowance(payer, msg.sender);
require(allowed >= lpAmount, "burn: allowance insufficient");
_approve(payer, msg.sender, allowed - lpAmount);
}
_burn(payer, lpAmount);
emit Burn(payer, receiver, withdrawAmounts, lpAmount);
}
/* ----------------------
Swaps
---------------------- */
/// @notice Swap input token i -> token j. Payer must approve token i.
/// @param payer address of the account that pays for the swap
/// @param receiver address that will receive the output tokens
/// @param i index of input asset
/// @param j index of output asset
/// @param maxAmountIn maximum amount of token i (uint256) to transfer in (inclusive of fees)
/// @param limitPrice maximum acceptable marginal price (64.64 fixed point). Pass 0 to ignore.
/// @param deadline timestamp after which the transaction will revert. Pass 0 to ignore.
/// @return amountIn actual input used (uint256), amountOut actual output sent (uint256)
function swap(
address payer,
address receiver,
uint256 i,
uint256 j,
uint256 maxAmountIn,
int128 limitPrice,
uint256 deadline
) external nonReentrant returns (uint256 amountIn, uint256 amountOut) {
uint256 n = tokens.length;
require(i < n && j < n, "swap: idx");
require(maxAmountIn > 0, "swap: input zero");
require(deadline == 0 || block.timestamp <= deadline, "swap: deadline exceeded");
// Read previous balances for affected assets
uint256 prevBalI = IERC20(tokens[i]).balanceOf(address(this));
uint256 prevBalJ = IERC20(tokens[j]).balanceOf(address(this));
// Calculate fee (ceiling) and net amount
(, uint256 netUintForSwap) = _computeFee(maxAmountIn);
// Convert the net amount to internal (floor)
int128 deltaInternalI = _uintToInternalFloor(netUintForSwap, bases[i]);
require(deltaInternalI > int128(0), "swap: input too small after fee");
// Make sure LMSR state exists
require(lmsr.nAssets > 0, "swap: empty pool");
// Compute swap amounts in internal space using exact-input logic (with limitPrice)
(int128 amountInInternalUsed, int128 amountOutInternal) = lmsr.swapAmountsForExactInput(
i,
j,
deltaInternalI,
limitPrice
);
// Convert actual used input internal -> uint (ceiling to protect the pool)
uint256 amountInUint = _internalToUintCeil(amountInInternalUsed, bases[i]);
// Total transfer amount includes fee calculated on the actual used input (ceiling)
uint256 totalTransferAmount = amountInUint;
if (swapFeePpm > 0) {
uint256 feeOnUsed = _ceilFee(amountInUint, swapFeePpm);
totalTransferAmount += feeOnUsed;
}
// Ensure we do not attempt to transfer more than the caller specified as maximum
require(totalTransferAmount > 0, 'swap: input zero');
require(totalTransferAmount <= maxAmountIn, "swap: transfer exceeds max");
// Transfer the exact amount from payer and require exact receipt (revert on fee-on-transfer)
_safeTransferFrom(tokens[i], payer, address(this), totalTransferAmount);
uint256 balIAfter = IERC20(tokens[i]).balanceOf(address(this));
require(balIAfter == prevBalI + totalTransferAmount, "swap: non-standard tokenIn");
// Compute output uint amount (floor)
uint256 amountOutUint = _internalToUintFloor(amountOutInternal, bases[j]);
require(amountOutUint > 0, "swap: output zero");
// Transfer output to receiver and verify exact decrease
_safeTransfer(tokens[j], receiver, amountOutUint);
uint256 balJAfter = IERC20(tokens[j]).balanceOf(address(this));
require(balJAfter == prevBalJ - amountOutUint, "swap: non-standard tokenOut");
// Update cached uint balances for i and j using actual balances
cachedUintBalances[i] = balIAfter;
cachedUintBalances[j] = balJAfter;
// Apply swap to LMSR state with the internal amounts actually used
// (fee is already accounted for in the reduced input amount)
lmsr.applySwap(i, j, amountInInternalUsed, amountOutInternal);
emit Swap(payer, receiver, tokens[i], tokens[j], totalTransferAmount, amountOutUint);
return (totalTransferAmount, amountOutUint);
}
/// @notice Swap up to the price limit; computes max input to reach limit then performs swap.
/// If the pool can't fill entirely because of balances, it caps appropriately and returns actuals.
/// Payer must approve token i for the exact computed input amount.
/// @param deadline timestamp after which the transaction will revert. Pass 0 to ignore.
function swapToLimit(
address payer,
address receiver,
uint256 i,
uint256 j,
int128 limitPrice,
uint256 deadline
) external returns (uint256 amountInUsed, uint256 amountOut) {
uint256 n = tokens.length;
require(i < n && j < n, "swapToLimit: idx");
require(limitPrice > int128(0), "swapToLimit: limit <= 0");
require(deadline == 0 || block.timestamp <= deadline, "swapToLimit: deadline exceeded");
// Ensure LMSR state exists
require(lmsr.nAssets > 0, "swapToLimit: pool uninitialized");
// Read previous balances for affected assets
uint256 prevBalI = IERC20(tokens[i]).balanceOf(address(this));
uint256 prevBalJ = IERC20(tokens[j]).balanceOf(address(this));
// Compute maxima in internal space using library
(int128 amountInInternalMax, int128 amountOutInternal) = lmsr.swapAmountsForPriceLimit(i, j, limitPrice);
// Calculate how much input will be needed with fee included (ceiling to protect the pool)
uint256 amountInUsedUint = _internalToUintCeil(amountInInternalMax, bases[i]);
require(amountInUsedUint > 0, "swapToLimit: input zero");
// Total transfer amount is the input amount including what will be taken as fee (ceiling)
uint256 totalTransferAmount = amountInUsedUint;
if (swapFeePpm > 0) {
uint256 feeOnUsed = _ceilFee(amountInUsedUint, swapFeePpm);
totalTransferAmount += feeOnUsed;
}
// Transfer the exact amount needed from payer and require exact receipt (revert on fee-on-transfer)
_safeTransferFrom(tokens[i], payer, address(this), totalTransferAmount);
uint256 balIAfter = IERC20(tokens[i]).balanceOf(address(this));
require(balIAfter == prevBalI + totalTransferAmount, "swapToLimit: non-standard tokenIn");
// Compute output amount (floor)
uint256 amountOutUint = _internalToUintFloor(amountOutInternal, bases[j]);
require(amountOutUint > 0, "swapToLimit: output zero");
// Transfer output to receiver and verify exact decrease
_safeTransfer(tokens[j], receiver, amountOutUint);
uint256 balJAfter = IERC20(tokens[j]).balanceOf(address(this));
require(balJAfter == prevBalJ - amountOutUint, "swapToLimit: non-standard tokenOut");
// Update caches to actual balances
cachedUintBalances[i] = balIAfter;
cachedUintBalances[j] = balJAfter;
// Apply swap to LMSR state with the internal amounts
// (fee is already part of the reduced effective input)
lmsr.applySwap(i, j, amountInInternalMax, amountOutInternal);
emit Swap(payer, receiver, tokens[i], tokens[j], amountInUsedUint, amountOutUint);
return (amountInUsedUint, amountOutUint);
}
/// @notice Ceiling fee helper: computes ceil(x * feePpm / 1_000_000)
function _ceilFee(uint256 x, uint256 feePpm) internal pure returns (uint256) {
if (feePpm == 0) return 0;
// ceil division: (num + denom - 1) / denom
return (x * feePpm + 1_000_000 - 1) / 1_000_000;
}
/// @notice Compute fee and net amounts for a gross input (fee rounded up to favor the pool).
/// @return feeUint fee taken (uint) and netUint remaining for protocol use (uint)
function _computeFee(uint256 gross) internal view returns (uint256 feeUint, uint256 netUint) {
if (swapFeePpm == 0) {
return (0, gross);
}
feeUint = _ceilFee(gross, swapFeePpm);
netUint = gross - feeUint;
}
/// @notice Convenience: return gross = net + fee(net) using ceiling for fee.
function _addFee(uint256 netUint) internal view returns (uint256 gross) {
if (swapFeePpm == 0) return netUint;
uint256 fee = _ceilFee(netUint, swapFeePpm);
return netUint + fee;
}
// --- New events for single-token mint/burn flows ---
// Note: events intentionally avoid exposing internal ΔS and avoid duplicating LP mint/burn data
// which is already present in the standard Mint/Burn events.
/// @notice Single-token mint: deposit a single token, charge swap-LMSR cost, and mint LP.
/// @param payer who transfers the input token
/// @param receiver who receives the minted LP tokens
/// @param i index of the input token
/// @param maxAmountIn maximum uint token input (inclusive of fee)
/// @param deadline optional deadline
/// @return lpMinted actual LP minted (uint)
function swapMint(
address payer,
address receiver,
uint256 i,
uint256 maxAmountIn,
uint256 deadline
) external nonReentrant returns (uint256 lpMinted) {
uint256 n = tokens.length;
require(i < n, "swapMint: idx");
require(maxAmountIn > 0, "swapMint: input zero");
require(deadline == 0 || block.timestamp <= deadline, "swapMint: deadline");
// Ensure pool initialized
require(lmsr.nAssets > 0, "swapMint: uninit pool");
// compute fee on gross maxAmountIn to get an initial net estimate (we'll recompute based on actual used)
(, uint256 netUintGuess) = _computeFee(maxAmountIn);
// Convert the net guess to internal (floor)
int128 netInternalGuess = _uintToInternalFloor(netUintGuess, bases[i]);
require(netInternalGuess > int128(0), "swapMint: input too small after fee");
// Use LMSR view to determine actual internal consumed and size-increase (ΔS) for mint
(int128 amountInInternalUsed, int128 sizeIncreaseInternal) = lmsr.swapAmountsForMint(i, netInternalGuess);
// amountInInternalUsed may be <= netInternalGuess. Convert to uint (ceil) to determine actual transfer
uint256 amountInUint = _internalToUintCeil(amountInInternalUsed, bases[i]);
require(amountInUint > 0, "swapMint: input zero after internal conversion");
// Compute fee on the actual used input and total transfer amount (ceiling)
uint256 feeUintActual = _ceilFee(amountInUint, swapFeePpm);
uint256 totalTransfer = amountInUint + feeUintActual;
require(totalTransfer > 0 && totalTransfer <= maxAmountIn, "swapMint: transfer exceeds max");
// Record pre-balance and transfer tokens from payer, require exact receipt (revert on fee-on-transfer)
uint256 prevBalI = IERC20(tokens[i]).balanceOf(address(this));
_safeTransferFrom(tokens[i], payer, address(this), totalTransfer);
uint256 balIAfter = IERC20(tokens[i]).balanceOf(address(this));
require(balIAfter == prevBalI + totalTransfer, "swapMint: non-standard tokenIn");
// Update cached uint balances for token i (only i changed externally)
cachedUintBalances[i] = balIAfter;
// Compute old and new scaled size metrics to determine LP minted
int128 oldTotal = _computeSizeMetric(lmsr.qInternal);
require(oldTotal > int128(0), "swapMint: zero total");
uint256 oldScaled = ABDKMath64x64.mulu(oldTotal, LP_SCALE);
int128 newTotal = oldTotal.add(sizeIncreaseInternal);
uint256 newScaled = ABDKMath64x64.mulu(newTotal, LP_SCALE);
uint256 actualLpToMint;
if (totalSupply() == 0) {
// If somehow supply zero (shouldn't happen as lmsr.nAssets>0), mint newScaled
actualLpToMint = newScaled;
} else {
require(oldScaled > 0, "swapMint: oldScaled zero");
uint256 delta = (newScaled > oldScaled) ? (newScaled - oldScaled) : 0;
if (delta > 0) {
// floor truncation rounds in favor of pool
actualLpToMint = (totalSupply() * delta) / oldScaled;
} else {
actualLpToMint = 0;
}
}
require(actualLpToMint > 0, "swapMint: zero LP minted");
// Update LMSR internal state: scale qInternal proportionally by newTotal/oldTotal
int128[] memory newQInternal = new int128[](n);
for (uint256 idx = 0; idx < n; idx++) {
// newQInternal[idx] = qInternal[idx] * (newTotal / oldTotal)
newQInternal[idx] = lmsr.qInternal[idx].mul(newTotal).div(oldTotal);
}
// Update cached internal and kappa via updateForProportionalChange
lmsr.updateForProportionalChange(newQInternal);
// Note: we updated cachedUintBalances[i] above via reading balance; other token uint balances did not
// change externally (they were not transferred in). We keep cachedUintBalances for others unchanged.
// Mint LP tokens to receiver
_mint(receiver, actualLpToMint);
// Emit SwapMint event with gross transfer, net input and fee (planned exact-in)
emit SwapMint(payer, receiver, i, totalTransfer, amountInUint, feeUintActual);
// Emit standard Mint event which records deposit amounts and LP minted
emit Mint(payer, receiver, new uint256[](n), actualLpToMint);
// Note: depositAmounts array omitted (empty) since swapMint uses single-token input
return actualLpToMint;
}
/// @notice Burn LP tokens then swap the redeemed proportional basket into a single asset `i` and send to receiver.
/// @param payer who burns LP tokens
/// @param receiver who receives the single asset
/// @param lpAmount amount of LP tokens to burn
/// @param i index of target asset to receive
/// @param deadline optional deadline
/// @return amountOutUint uint amount of asset i sent to receiver
function burnSwap(
address payer,
address receiver,
uint256 lpAmount,
uint256 i,
uint256 deadline
) external nonReentrant returns (uint256 amountOutUint) {
uint256 n = tokens.length;
require(i < n, "burnSwap: idx");
require(lpAmount > 0, "burnSwap: zero lp");
require(deadline == 0 || block.timestamp <= deadline, "burnSwap: deadline");
uint256 supply = totalSupply();
require(supply > 0, "burnSwap: empty supply");
require(balanceOf(payer) >= lpAmount, "burnSwap: insufficient LP");
// alpha = lpAmount / supply as Q64.64
int128 alpha = ABDKMath64x64.divu(lpAmount, supply);
// Use LMSR view to compute single-asset payout and burned size-metric
(int128 payoutInternal, ) = lmsr.swapAmountsForBurn(i, alpha);
// Convert payoutInternal -> uint (floor) to favor pool
amountOutUint = _internalToUintFloor(payoutInternal, bases[i]);
require(amountOutUint > 0, "burnSwap: output zero");
// Transfer the payout to receiver
_safeTransfer(tokens[i], receiver, amountOutUint);
// Burn LP tokens from payer (authorization via allowance)
if (msg.sender != payer) {
uint256 allowed = allowance(payer, msg.sender);
require(allowed >= lpAmount, "burnSwap: allowance insufficient");
_approve(payer, msg.sender, allowed - lpAmount);
}
_burn(payer, lpAmount);
// Update cached balances by reading on-chain balances for all tokens
int128[] memory newQInternal = new int128[](n);
for (uint256 idx = 0; idx < n; idx++) {
uint256 bal = IERC20(tokens[idx]).balanceOf(address(this));
cachedUintBalances[idx] = bal;
newQInternal[idx] = _uintToInternalFloor(bal, bases[idx]);
}
// Emit BurnSwap with public-facing info only (do not expose ΔS or LP burned)
emit BurnSwap(payer, receiver, i, amountOutUint);
// If entire pool drained, deinit; else update proportionally
bool allZero = true;
for (uint256 idx = 0; idx < n; idx++) {
if (newQInternal[idx] != int128(0)) { allZero = false; break; }
}
if (allZero) {
lmsr.deinit();
} else {
lmsr.updateForProportionalChange(newQInternal);
}
emit Burn(payer, receiver, new uint256[](n), lpAmount);
return amountOutUint;
}
function computeFlashRepaymentAmounts(uint256[] memory loanAmounts) external view
returns (uint256[] memory repaymentAmounts) {
repaymentAmounts = new uint256[](tokens.length);
for (uint256 i = 0; i < tokens.length; i++) {
uint256 amount = loanAmounts[i];
if (amount > 0) {
repaymentAmounts[i] = amount + _ceilFee(amount, flashFeePpm);
}
}
}
/// @notice Receive token0 and/or token1 and pay it back, plus a fee, in the callback
/// @dev The caller of this method receives a callback in the form of IPartyFlashCallback#partyFlashCallback
/// @param recipient The address which will receive the token amounts
/// @param amounts The amount of each token to send
/// @param data Any data to be passed through to the callback
function flash(
address recipient,
uint256[] memory amounts,
bytes calldata data
) external nonReentrant {
require(recipient != address(0), "flash: zero recipient");
require(amounts.length == tokens.length, "flash: amounts length mismatch");
// Calculate repayment amounts for each token including fee
uint256[] memory repaymentAmounts = new uint256[](tokens.length);
// Store initial balances to verify repayment later
uint256[] memory initialBalances = new uint256[](tokens.length);
// Track if any token amount is non-zero
bool hasNonZeroAmount = false;
// Process each token, skipping those with zero amounts
for (uint256 i = 0; i < tokens.length; i++) {
uint256 amount = amounts[i];
if (amount > 0) {
hasNonZeroAmount = true;
// Calculate repayment amount with fee (ceiling)
repaymentAmounts[i] = amount + _ceilFee(amount, flashFeePpm);
// Record initial balance
initialBalances[i] = IERC20(tokens[i]).balanceOf(address(this));
// Transfer token to recipient
_safeTransfer(tokens[i], recipient, amount);
}
}
// Ensure at least one token is being borrowed
require(hasNonZeroAmount, "flash: no tokens requested");
// Call flash callback with expected repayment amounts
IPartyFlashCallback(msg.sender).partyFlashCallback(amounts, repaymentAmounts, data);
// Verify repayment amounts for tokens that were borrowed
for (uint256 i = 0; i < tokens.length; i++) {
if (amounts[i] > 0) {
uint256 currentBalance = IERC20(tokens[i]).balanceOf(address(this));
// Verify repayment: current balance must be at least (initial balance + fee)
require(
currentBalance >= initialBalances[i] + _ceilFee(amounts[i], flashFeePpm),
"flash: repayment failed"
);
// Update cached balance
cachedUintBalances[i] = currentBalance;
}
}
}
/* ----------------------
Conversion helpers
---------------------- */
// Convert uint token amount -> internal 64.64 (floor). Uses ABDKMath64x64.divu which truncates.
function _uintToInternalFloor(uint256 amount, uint256 base) internal pure returns (int128) {
// internal = amount / base (as Q64.64)
return ABDKMath64x64.divu(amount, base);
}
// Convert internal 64.64 -> uint token amount (floor). Uses ABDKMath64x64.mulu which floors the product.
function _internalToUintFloor(int128 internalAmount, uint256 base) internal pure returns (uint256) {
// uint = internal * base (floored)
return ABDKMath64x64.mulu(internalAmount, base);
}
// Convert internal 64.64 -> uint token amount (ceiling). Rounds up to protect the pool.
function _internalToUintCeil(int128 internalAmount, uint256 base) internal pure returns (uint256) {
// Get the floor value first
uint256 floorValue = ABDKMath64x64.mulu(internalAmount, base);
// Check if there was any fractional part by comparing to a reconstruction of the original
int128 reconstructed = ABDKMath64x64.divu(floorValue, base);
// If reconstructed is less than original, there was a fractional part that was truncated
if (reconstructed < internalAmount) {
return floorValue + 1;
}
return floorValue;
}
/* ----------------------
ERC20 helpers (minimal)
---------------------- */
function _safeTransferFrom(address token, address from, address to, uint256 amt) internal {
IERC20(token).safeTransferFrom(from, to, amt);
}
function _safeTransfer(address token, address to, uint256 amt) internal {
IERC20(token).safeTransfer(to, amt);
}
/// @notice Helper to compute size metric (sum of all asset quantities) from internal balances
function _computeSizeMetric(int128[] memory qInternal_) private pure returns (int128) {
int128 total = int128(0);
for (uint i = 0; i < qInternal_.length; ) {
total = total.add(qInternal_[i]);
unchecked { i++; }
}
return total;
}
}

24
test/Counter.t.sol Normal file
View File

@@ -0,0 +1,24 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
import {Counter} from "../src/Counter.sol";
contract CounterTest is Test {
Counter public counter;
function setUp() public {
counter = new Counter();
counter.setNumber(0);
}
function test_Increment() public {
counter.increment();
assertEq(counter.number(), 1);
}
function testFuzz_SetNumber(uint256 x) public {
counter.setNumber(x);
assertEq(counter.number(), x);
}
}

861
test/LMSRStabilized.t.sol Normal file
View File

@@ -0,0 +1,861 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "forge-std/console.sol";
import "@openzeppelin/contracts/interfaces/IERC20Metadata.sol";
import "../src/LMSRStabilized.sol";
/// @notice Forge tests for LMSRStabilized
contract LMSRStabilizedTest is Test {
using LMSRStabilized for LMSRStabilized.State;
using ABDKMath64x64 for int128;
LMSRStabilized.State internal s;
int128 stdTradeSize;
int128 stdSlippage;
function setUp() public {
// 0.10% slippage when taking 1.00% of the assets
stdTradeSize = ABDKMath64x64.divu(100,10_000);
stdSlippage = ABDKMath64x64.divu(10,10_000);
}
function initBalanced() internal {
int128[] memory q = new int128[](3);
q[0] = ABDKMath64x64.fromUInt(1_000_000);
q[1] = ABDKMath64x64.fromUInt(1_000_000);
q[2] = ABDKMath64x64.fromUInt(1_000_000);
s.init(q, stdTradeSize, stdSlippage);
}
function initAlmostBalanced() internal {
int128[] memory q = new int128[](3);
q[0] = ABDKMath64x64.fromUInt(999_999);
q[1] = ABDKMath64x64.fromUInt(1_000_000);
q[2] = ABDKMath64x64.fromUInt(1_000_001);
s.init(q, stdTradeSize, stdSlippage);
}
function initImbalanced() internal {
int128[] memory q = new int128[](4);
q[0] = ABDKMath64x64.fromUInt(1);
q[1] = ABDKMath64x64.fromUInt(1e9);
q[2] = ABDKMath64x64.fromUInt(1);
q[3] = ABDKMath64x64.divu(1, 1e9);
s.init(q, stdTradeSize, stdSlippage);
}
function testInitBalanced() public {
// Test 1: Balanced Pool Initialization
initBalanced();
// Create mock qInternal for testing
int128[] memory mockQInternal = new int128[](3);
mockQInternal[0] = ABDKMath64x64.fromUInt(1_000_000);
mockQInternal[1] = ABDKMath64x64.fromUInt(1_000_000);
mockQInternal[2] = ABDKMath64x64.fromUInt(1_000_000);
// Update the state's cached qInternal
_updateCachedQInternal(mockQInternal);
// Verify slippage by performing asset swaps and checking price impact
int128 tradeAmount = mockQInternal[0].mul(stdTradeSize);
// For a balanced pool, test asset 0 -> asset 1 swap
(int128 amountIn, int128 amountOut) = s.swapAmountsForExactInput(0, 1, tradeAmount, 0);
// Verify amountIn and amountOut are reasonable
assertTrue(amountIn > 0, "amountIn should be positive");
assertTrue(amountOut > 0, "amountOut should be positive");
// Calculate slippage = (initialPrice/finalPrice - 1)
// Compute e values dynamically for price ratio
int128 b = _computeB(mockQInternal);
int128[] memory eValues = _computeE(b, mockQInternal);
// For balanced pool, initial price ratio is 1:1
int128 initialRatio = eValues[0].div(eValues[1]);
// Verify initial ratio for balanced pool is approximately 1:1
assertTrue((initialRatio.sub(ABDKMath64x64.fromInt(1))).abs() < ABDKMath64x64.divu(1, 10000),
"Initial price ratio should be close to 1:1");
// After trade, the new e values would be different
int128 newE0 = eValues[0].mul(_exp(tradeAmount.div(b)));
int128 slippageRatio = newE0.div(eValues[0]).div(eValues[1].div(eValues[1]));
int128 slippage = slippageRatio.sub(ABDKMath64x64.fromInt(1));
console2.log('slippage', slippage);
// Slippage should be close to stdSlippage (within 1% relative error)
int128 relativeError = slippage.sub(stdSlippage).abs().div(stdSlippage);
assertLt(relativeError, ABDKMath64x64.divu(1, 100), "Balanced pool slippage error too high");
}
function testInitAlmostBalanced() public {
// Test 2: Almost Balanced Pool Initialization
initAlmostBalanced();
// Create mock qInternal for testing
int128[] memory mockQInternal = new int128[](3);
mockQInternal[0] = ABDKMath64x64.fromUInt(999_999);
mockQInternal[1] = ABDKMath64x64.fromUInt(1_000_000);
mockQInternal[2] = ABDKMath64x64.fromUInt(1_000_001);
// Update the state's cached qInternal
_updateCachedQInternal(mockQInternal);
// Verify slippage for almost balanced pool
int128 tradeAmount = mockQInternal[0].mul(stdTradeSize);
(int128 amountIn, int128 amountOut) = s.swapAmountsForExactInput(0, 1, tradeAmount, 0);
// Verify amountIn and amountOut are reasonable
assertTrue(amountIn > 0, "amountIn should be positive");
assertTrue(amountOut > 0, "amountOut should be positive");
// Compute e values dynamically for price ratio
int128 b = _computeB(mockQInternal);
int128[] memory eValues = _computeE(b, mockQInternal);
int128 initialRatio = eValues[0].div(eValues[1]);
int128 relDiff = (initialRatio.sub(ABDKMath64x64.fromInt(1))).abs();
// Verify the initial ratio is close to but not exactly 1:1
assertTrue(relDiff < ABDKMath64x64.divu(1, 1000),
"Initial ratio should be close to 1:1 for almost balanced pool");
assertTrue(relDiff > ABDKMath64x64.divu(1, 10000000),
"Initial ratio should not be exactly 1:1 for almost balanced pool");
int128 newE0 = eValues[0].mul(_exp(tradeAmount.div(b)));
int128 slippageRatio = newE0.div(eValues[0]).div(eValues[1].div(eValues[1]));
int128 slippage = slippageRatio.sub(ABDKMath64x64.fromInt(1));
console2.log('slippage', slippage);
int128 relativeError = slippage.sub(stdSlippage).abs().div(stdSlippage);
assertLt(relativeError, ABDKMath64x64.divu(1, 100), "Almost balanced pool slippage error too high");
}
function testInitImbalanced() public {
// Test 3: Imbalanced Pool Initialization
initImbalanced();
// Create mock qInternal for testing
int128[] memory mockQInternal = new int128[](4);
mockQInternal[0] = ABDKMath64x64.fromUInt(1);
mockQInternal[1] = ABDKMath64x64.fromUInt(1e9);
mockQInternal[2] = ABDKMath64x64.fromUInt(1);
mockQInternal[3] = ABDKMath64x64.divu(1, 1e9);
// Update the state's cached qInternal
_updateCachedQInternal(mockQInternal);
// For imbalanced pool, we need to try an "average" swap
// We'll use asset 0 -> asset 2 as it's more balanced than asset 0 -> asset 1
int128 tradeAmount = mockQInternal[0].mul(stdTradeSize);
// Compute e values dynamically for price ratio
int128 b = _computeB(mockQInternal);
int128[] memory eValues = _computeE(b, mockQInternal);
// Verify the ratios between small and large assets is different
int128 initialRatio = eValues[0].div(eValues[3]); // Assets 0 and 2 match, and assets 1 and 3 match. 0 and 3 differ.
int128 relDiff = (initialRatio.sub(ABDKMath64x64.fromInt(1))).abs();
// Verify initial ratio shows significant imbalance
assertTrue(relDiff != 0, "Initial ratio should show imbalance");
(int128 amountIn, int128 amountOut) = s.swapAmountsForExactInput(0, 2, tradeAmount, 0);
// Verify amountIn and amountOut are reasonable
assertTrue(amountIn > 0, "amountIn should be positive");
assertTrue(amountOut > 0, "amountOut should be positive");
int128 newE0 = eValues[0].mul(_exp(tradeAmount.div(b)));
int128 slippageRatio = newE0.div(eValues[0]).div(eValues[2].div(eValues[2]));
int128 slippage = slippageRatio.sub(ABDKMath64x64.fromInt(1));
console2.log('slippage', slippage);
// Since the imbalance is extreme, with one coin worth lots more than the others, the actual slippage for
// this swap is actually off by about 100%
// When we configure kappa, it is a best case slippage (worst case AMM loss) that only occurs with balanced
// assets
int128 relativeError = slippage.sub(stdSlippage).abs().div(stdSlippage);
console2.log('relative error', relativeError);
assertLt(relativeError, ABDKMath64x64.divu(100, 100), "Imbalanced pool slippage error too high");
}
function testRecentering() public {
// Recentering functionality has been removed since we no longer cache intermediate values
// This test is now a no-op but kept for API compatibility
initAlmostBalanced();
// Verify basic state is still functional
assertTrue(s.nAssets > 0, "State should still be initialized");
assertTrue(s.kappa > int128(0), "Kappa should still be positive");
}
function testRescalingAfterDeposit() public {
// Initialize pool with almost balanced assets
initAlmostBalanced();
// Create initial asset quantities
int128[] memory initialQ = new int128[](3);
initialQ[0] = ABDKMath64x64.fromUInt(999_999);
initialQ[1] = ABDKMath64x64.fromUInt(1_000_000);
initialQ[2] = ABDKMath64x64.fromUInt(1_000_001);
// Update the state's cached qInternal
_updateCachedQInternal(initialQ);
// Store initial parameters
int128 initialB = _computeB(initialQ);
int128 initialKappa = s.kappa;
// Simulate a deposit by increasing all asset quantities by 50%
int128[] memory newQ = new int128[](s.nAssets);
for (uint i = 0; i < s.nAssets; i++) {
// Increase by 50%
newQ[i] = initialQ[i].mul(ABDKMath64x64.fromUInt(3).div(ABDKMath64x64.fromUInt(2))); // 1.5x
}
// Apply the update for proportional change
s.updateForProportionalChange(newQ);
// Verify that b has been rescaled proportionally
int128 newB = _computeB(s.qInternal);
int128 expectedRatio = ABDKMath64x64.fromUInt(3).div(ABDKMath64x64.fromUInt(2)); // 1.5x
int128 actualRatio = newB.div(initialB);
int128 tolerance = ABDKMath64x64.divu(1, 1000); // 0.1% tolerance
assertTrue((actualRatio.sub(expectedRatio)).abs() < tolerance, "b did not scale proportionally after deposit");
// Verify kappa remained unchanged
assertTrue((s.kappa.sub(initialKappa)).abs() < tolerance, "kappa should not change after deposit");
// Verify slippage target is still met by performing a trade
int128 tradeAmount = s.qInternal[0].mul(stdTradeSize);
(int128 amountIn, int128 amountOut) = s.swapAmountsForExactInput(0, 1, tradeAmount, 0);
// Verify computed swap amounts
assertTrue(amountIn > 0, "Swap amountIn should be positive");
assertTrue(amountOut > 0, "Swap amountOut should be positive");
// Verify amountOut is reasonable compared to amountIn (not a severe loss)
assertTrue(amountOut.div(amountIn) > ABDKMath64x64.divu(9, 10), "Swap should not incur severe loss");
int128[] memory eValues = _computeE(newB, s.qInternal);
int128 newE0 = eValues[0].mul(_exp(tradeAmount.div(newB)));
int128 slippageRatio = newE0.div(eValues[0]).div(eValues[1].div(eValues[1]));
int128 slippage = slippageRatio.sub(ABDKMath64x64.fromInt(1));
console2.log('post-deposit slippage', slippage);
int128 relativeError = slippage.sub(stdSlippage).abs().div(stdSlippage);
assertLt(relativeError, ABDKMath64x64.divu(1, 100), "Slippage target not met after deposit");
}
function testRescalingAfterWithdrawal() public {
// Initialize pool with almost balanced assets
initAlmostBalanced();
// Create initial asset quantities
int128[] memory initialQ = new int128[](3);
initialQ[0] = ABDKMath64x64.fromUInt(999_999);
initialQ[1] = ABDKMath64x64.fromUInt(1_000_000);
initialQ[2] = ABDKMath64x64.fromUInt(1_000_001);
// Update the state's cached qInternal
_updateCachedQInternal(initialQ);
// Store initial parameters
int128 initialB = _computeB(initialQ);
int128 initialKappa = s.kappa;
// Simulate a withdrawal by decreasing all asset quantities by 30%
int128[] memory newQ = new int128[](s.nAssets);
for (uint i = 0; i < s.nAssets; i++) {
// Decrease by 30%
newQ[i] = initialQ[i].mul(ABDKMath64x64.fromUInt(7).div(ABDKMath64x64.fromUInt(10))); // 0.7x
}
// Apply the update for proportional change
s.updateForProportionalChange(newQ);
// Verify that b has been rescaled proportionally
int128 newB = _computeB(s.qInternal);
int128 expectedRatio = ABDKMath64x64.fromUInt(7).div(ABDKMath64x64.fromUInt(10)); // 0.7x
int128 actualRatio = newB.div(initialB);
int128 tolerance = ABDKMath64x64.divu(1, 1000); // 0.1% tolerance
assertTrue((actualRatio.sub(expectedRatio)).abs() < tolerance, "b did not scale proportionally after withdrawal");
// Verify kappa remained unchanged
assertTrue((s.kappa.sub(initialKappa)).abs() < tolerance, "kappa should not change after withdrawal");
// Verify slippage target is still met by performing a trade
int128 tradeAmount = s.qInternal[0].mul(stdTradeSize);
(int128 amountIn, int128 amountOut) = s.swapAmountsForExactInput(0, 1, tradeAmount, 0);
// Verify computed swap amounts
assertTrue(amountIn > 0, "Swap amountIn should be positive");
assertTrue(amountOut > 0, "Swap amountOut should be positive");
// Verify amountOut is reasonable compared to amountIn (not a severe loss)
assertTrue(amountOut.div(amountIn) > ABDKMath64x64.divu(9, 10), "Swap should not incur severe loss");
int128[] memory eValues = _computeE(newB, s.qInternal);
int128 newE0 = eValues[0].mul(_exp(tradeAmount.div(newB)));
int128 slippageRatio = newE0.div(eValues[0]).div(eValues[1].div(eValues[1]));
int128 slippage = slippageRatio.sub(ABDKMath64x64.fromInt(1));
console2.log('post-withdrawal slippage', slippage);
int128 relativeError = slippage.sub(stdSlippage).abs().div(stdSlippage);
assertLt(relativeError, ABDKMath64x64.divu(1, 100), "Slippage target not met after withdrawal");
}
// --- tests probing numerical stability and boundary conditions ---
/// @notice Recentering functionality has been removed - this test is now a no-op
function testRecenterShiftTooLargeReverts() public {
initAlmostBalanced();
// Recentering has been removed, so this test now just verifies basic functionality
assertTrue(s.nAssets > 0, "State should still be initialized");
}
/// @notice limitPrice <= current price should revert (no partial fill)
function testLimitPriceRevertWhenAtOrBelowCurrent() public {
initBalanced();
// Create mock qInternal for testing
int128[] memory mockQInternal = new int128[](3);
mockQInternal[0] = ABDKMath64x64.fromUInt(1_000_000);
mockQInternal[1] = ABDKMath64x64.fromUInt(1_000_000);
mockQInternal[2] = ABDKMath64x64.fromUInt(1_000_000);
// Update the state's cached qInternal
_updateCachedQInternal(mockQInternal);
// For balanced pool r0 = 1. Use limitPrice == 1 which should revert.
int128 tradeAmount = mockQInternal[0].mul(stdTradeSize);
vm.expectRevert(bytes("LMSR: limitPrice <= current price"));
this.externalSwapAmountsForExactInput(0, 1, tradeAmount, ABDKMath64x64.fromInt(1));
}
/// @notice If e_j == 0 we should revert early to avoid div-by-zero
function testEJZeroReverts() public {
initBalanced();
// Create mock qInternal where asset 1 has zero quantity
int128[] memory mockQInternal = new int128[](3);
mockQInternal[0] = ABDKMath64x64.fromUInt(1_000_000);
mockQInternal[1] = int128(0); // Zero quantity for asset 1
mockQInternal[2] = ABDKMath64x64.fromUInt(1_000_000);
// Update the state's cached qInternal
_updateCachedQInternal(mockQInternal);
int128 tradeAmount = mockQInternal[0].mul(stdTradeSize);
vm.expectRevert(bytes("LMSR: e_j==0"));
this.externalSwapAmountsForExactInput(0, 1, tradeAmount, 0);
}
/// @notice swapAmountsForPriceLimit returns zero if limit equals current price
function testSwapAmountsForPriceLimitZeroWhenLimitEqualsPrice() public {
initBalanced();
// Create mock qInternal for testing
int128[] memory mockQInternal = new int128[](3);
mockQInternal[0] = ABDKMath64x64.fromUInt(1_000_000);
mockQInternal[1] = ABDKMath64x64.fromUInt(1_000_000);
mockQInternal[2] = ABDKMath64x64.fromUInt(1_000_000);
// Update the state's cached qInternal
_updateCachedQInternal(mockQInternal);
// For balanced pool r0 = 1. swapAmountsForPriceLimit with limit==1 should be zero
vm.expectRevert('LMSR: limitPrice <= current price');
this.externalSwapAmountsForPriceLimit(0, 1, ABDKMath64x64.fromInt(1));
// Try with a limit price slightly above 1, which should not revert
try this.externalSwapAmountsForPriceLimit(0, 1, ABDKMath64x64.fromInt(1).add(ABDKMath64x64.divu(1, 1000))) returns (int128 _amountIn, int128 _maxOut) {
// Verify that the returned values are reasonable
assertTrue(_amountIn > 0, "amountIn should be positive for valid limit price");
assertTrue(_maxOut > 0, "maxOut should be positive for valid limit price");
} catch {
fail("Should not revert with limit price > current price");
}
}
function externalSwapAmountsForPriceLimit(uint256 i, uint256 j, int128 limitPrice) external view
returns (int128, int128) {
return s.swapAmountsForPriceLimit(i, j, limitPrice);
}
/// @notice Gas/throughput test: perform 100 alternating swaps between asset 0 and 1
function testSwapGas() public {
// Initialize the almost-balanced pool
initAlmostBalanced();
// Create mock qInternal that we'll update through swaps
int128[] memory currentQ = new int128[](3);
currentQ[0] = ABDKMath64x64.fromUInt(999_999);
currentQ[1] = ABDKMath64x64.fromUInt(1_000_000);
currentQ[2] = ABDKMath64x64.fromUInt(1_000_001);
// Update the state's cached qInternal
_updateCachedQInternal(currentQ);
// Perform 100 swaps, alternating between asset 0 -> 1 and 1 -> 0
for (uint256 iter = 0; iter < 100; iter++) {
uint256 from = (iter % 2 == 0) ? 0 : 1;
uint256 to = (from == 0) ? 1 : 0;
// Use standard trade size applied to the 'from' asset's current quantity
int128 tradeAmount = s.qInternal[from].mul(stdTradeSize);
// Compute swap amounts and apply to state
(int128 amountIn, int128 amountOut) = s.swapAmountsForExactInput(from, to, tradeAmount, 0);
// applySwap now updates the internal qInternal directly
s.applySwap(from, to, amountIn, amountOut);
}
}
/// @notice Extremely large a that makes a/b exceed expLimit should revert
function testAmountOutABOverflowReverts() public {
initBalanced();
// Create mock qInternal for testing
int128[] memory mockQInternal = new int128[](3);
mockQInternal[0] = ABDKMath64x64.fromUInt(1_000_000);
mockQInternal[1] = ABDKMath64x64.fromUInt(1_000_000);
mockQInternal[2] = ABDKMath64x64.fromUInt(1_000_000);
// Update the state's cached qInternal
_updateCachedQInternal(mockQInternal);
int128 b = _computeB(mockQInternal);
// Pick a such that a/b = 33 (expLimit is 32). a = b * 33
int128 aOverB_target = ABDKMath64x64.fromInt(33);
int128 a = b.mul(aOverB_target);
vm.expectRevert(bytes("LMSR: a/b too large (would overflow exp)"));
this.externalSwapAmountsForExactInput(0, 1, a, 0);
}
// Helper function to compute b from qInternal (either from provided array or state)
function _computeB(int128[] memory qInternal) internal view returns (int128) {
int128 sizeMetric = _computeSizeMetric(qInternal);
require(sizeMetric > int128(0), "LMSR: size metric zero");
return s.kappa.mul(sizeMetric);
}
// Overload that uses state's cached qInternal
function _computeB() internal view returns (int128) {
int128 sizeMetric = _computeSizeMetric(s.qInternal);
require(sizeMetric > int128(0), "LMSR: size metric zero");
return s.kappa.mul(sizeMetric);
}
// Helper function to compute size metric (sum of all asset quantities)
function _computeSizeMetric(int128[] memory qInternal) internal pure returns (int128) {
int128 total = int128(0);
for (uint i = 0; i < qInternal.length; ) {
total = total.add(qInternal[i]);
unchecked { i++; }
}
return total;
}
// Helper function to update the state's cached qInternal
function _updateCachedQInternal(int128[] memory mockQInternal) internal {
// First ensure qInternal array exists with the right size
if (s.qInternal.length != mockQInternal.length) {
s.qInternal = new int128[](mockQInternal.length);
}
// Copy values from mockQInternal to state's qInternal
for (uint i = 0; i < mockQInternal.length; ) {
s.qInternal[i] = mockQInternal[i];
unchecked { i++; }
}
}
// Helper function to compute M and Z dynamically
function _computeMAndZ(int128 b, int128[] memory qInternal) internal pure returns (int128 M, int128 Z) {
require(qInternal.length > 0, "LMSR: no assets");
// Compute y_i = q_i / b for numerical stability
int128[] memory y = new int128[](qInternal.length);
for (uint i = 0; i < qInternal.length; ) {
y[i] = qInternal[i].div(b);
unchecked { i++; }
}
// Find max y for centering (M = maxY)
M = y[0];
for (uint i = 1; i < qInternal.length; ) {
if (y[i] > M) M = y[i];
unchecked { i++; }
}
// Compute Z = sum of exp(z_i) where z_i = y_i - M
Z = int128(0);
for (uint i = 0; i < qInternal.length; ) {
int128 z_i = y[i].sub(M);
int128 e_i = _exp(z_i);
Z = Z.add(e_i);
unchecked { i++; }
}
}
// Helper function to compute all e[i] = exp(z[i]) values dynamically
function _computeE(int128 b, int128[] memory qInternal) internal pure returns (int128[] memory e) {
(int128 M, ) = _computeMAndZ(b, qInternal);
e = new int128[](qInternal.length);
for (uint i = 0; i < qInternal.length; ) {
int128 y_i = qInternal[i].div(b);
int128 z_i = y_i.sub(M);
e[i] = _exp(z_i);
unchecked { i++; }
}
}
// Helper function to calculate exp (copied from LMSRStabilized library)
function _exp(int128 x) internal pure returns (int128) {
return ABDKMath64x64.exp(x);
}
// External helper function that wraps swapAmountsForExactInput to properly handle reverts in tests
function externalSwapAmountsForExactInput(
uint i,
uint j,
int128 a,
int128 limitPrice
) external view returns (int128 amountIn, int128 amountOut) {
return s.swapAmountsForExactInput(i, j, a, limitPrice);
}
// External helper function that wraps recenterIfNeeded to properly handle reverts in tests
function externalRecenterIfNeeded() external {
// Recentering has been removed - this is now a no-op
}
// External helper function that wraps applySwap to properly handle reverts in tests
function externalApplySwap(
uint i,
uint j,
int128 amountIn,
int128 amountOut
) external {
s.applySwap(i, j, amountIn, amountOut);
}
// Small helper: convert a Q64.64 int128 into micro-units (value * 1e6) as an int256 for readable logging.
// Example: if x represents 0.001 (Q64.64), _toMicro(x) will return ~1000.
function _toMicro(int128 x) internal pure returns (int256) {
int256 ONE = int256(uint256(0x10000000000000000)); // 2^64
return (int256(x) * 1_000_000) / ONE;
}
/// @notice Test that applySwap correctly validates swap parameters and updates qInternal
function testApplySwap() public {
// Initialize with balanced assets
initBalanced();
// Create mock qInternal for testing
int128[] memory mockQInternal = new int128[](3);
mockQInternal[0] = ABDKMath64x64.fromUInt(1_000_000);
mockQInternal[1] = ABDKMath64x64.fromUInt(1_000_000);
mockQInternal[2] = ABDKMath64x64.fromUInt(1_000_000);
// Update the state's cached qInternal
_updateCachedQInternal(mockQInternal);
// Save original values for comparison
int128 originalQ0 = s.qInternal[0];
int128 originalQ1 = s.qInternal[1];
// Calculate swap amounts from asset 0 to asset 1
int128 tradeAmount = mockQInternal[0].mul(stdTradeSize);
(int128 amountIn, int128 amountOut) = s.swapAmountsForExactInput(0, 1, tradeAmount, 0);
// Verify basic swap calculation worked
assertTrue(amountIn > 0, "amountIn should be positive");
assertTrue(amountOut > 0, "amountOut should be positive");
// Apply the swap - should not revert for valid inputs
s.applySwap(0, 1, amountIn, amountOut);
// Verify qInternal is correctly updated
// Input asset should increase by amountIn
assertEq(s.qInternal[0], originalQ0.add(amountIn), "qInternal[0] should be updated");
// Output asset should decrease by amountOut
assertEq(s.qInternal[1], originalQ1.sub(amountOut), "qInternal[1] should be updated");
}
/// @notice Test path independence by comparing direct vs indirect swaps
function testPathIndependence() public {
// Start with a balanced pool
initBalanced();
// Create initial quantities
int128[] memory initialQValues = new int128[](s.nAssets);
initialQValues[0] = ABDKMath64x64.fromUInt(1_000_000);
initialQValues[1] = ABDKMath64x64.fromUInt(1_000_000);
initialQValues[2] = ABDKMath64x64.fromUInt(1_000_000);
// Update the state's cached qInternal
_updateCachedQInternal(initialQValues);
// Test path independence by computing swap outcomes without state changes
int128 directSwapAmount = initialQValues[0].mul(stdTradeSize);
// Store a backup of the original values to restore between swaps
int128[] memory backupQ = new int128[](s.nAssets);
for (uint i = 0; i < s.nAssets; i++) {
backupQ[i] = s.qInternal[i];
}
// Path 1: Direct swap from asset 0 to asset 2
(int128 directAmountIn, int128 directAmountOut) = s.swapAmountsForExactInput(0, 2, directSwapAmount, 0);
// Restore original state for second path
_updateCachedQInternal(backupQ);
// Path 2: Swap from asset 0 to asset 1, then from asset 1 to asset 2
(int128 indirectAmountIn1, int128 indirectAmountOut1) = s.swapAmountsForExactInput(0, 1, directSwapAmount, 0);
// Update state for second leg of indirect path
s.qInternal[0] = s.qInternal[0].sub(indirectAmountIn1);
s.qInternal[1] = s.qInternal[1].add(indirectAmountOut1);
// Second swap: asset 1 -> asset 2
(int128 indirectAmountIn2, int128 indirectAmountOut2) = s.swapAmountsForExactInput(1, 2, indirectAmountOut1, 0);
// The path independence property isn't perfect due to discrete swap mechanics,
// but the difference should be within reasonable bounds
console2.log("Direct swap output:");
console2.logInt(directAmountOut);
console2.log("Indirect swap total output:");
console2.logInt(indirectAmountOut2);
// Basic verification that both paths produce positive outputs
assertTrue(directAmountOut > 0, "Direct swap should produce positive output");
assertTrue(indirectAmountOut2 > 0, "Indirect swap should produce positive output");
}
/// @notice Test round-trip trades to verify near-zero slippage
function testRoundTripTradesAcrossAllPools() public {
// Test with balanced pool only since we removed state caching
initBalanced();
// Create mock qInternal
int128[] memory initialQ = new int128[](3);
initialQ[0] = ABDKMath64x64.fromUInt(1_000_000);
initialQ[1] = ABDKMath64x64.fromUInt(1_000_000);
initialQ[2] = ABDKMath64x64.fromUInt(1_000_000);
// Update the state's cached qInternal
_updateCachedQInternal(initialQ);
console2.log("Testing round-trip trades for balanced pool");
// Use standard trade size
int128 tradeAmount = s.qInternal[0].mul(stdTradeSize);
// Step 1: Swap asset 0 -> asset 1
(int128 amountIn1, int128 amountOut1) = s.swapAmountsForExactInput(0, 1, tradeAmount, 0);
// Update quantities for step 2
s.qInternal[0] = s.qInternal[0].sub(amountIn1);
s.qInternal[1] = s.qInternal[1].add(amountOut1);
// Step 2: Swap back asset 1 -> asset 0
(int128 amountIn2, int128 amountOut2) = s.swapAmountsForExactInput(1, 0, amountOut1, 0);
// Calculate round-trip slippage: (initial amount - final amount) / initial amount
int128 roundTripSlippage = (amountIn1.sub(amountOut2)).div(amountIn1);
console2.log("Round-trip slippage (micro-units):");
console2.logInt(_toMicro(roundTripSlippage));
// Verify round-trip slippage is reasonable
int128 tolerance = ABDKMath64x64.divu(1, 100000); // 0.001% tolerance
assertLt(roundTripSlippage.abs(), tolerance, "Round-trip slippage should be near zero");
}
/// @notice Test that slippage is approximately equal in both directions for small swaps
function testBidirectionalSlippageSymmetry() public {
// Initialize with balanced assets for clearest slippage measurement
initBalanced();
// Create mock qInternal
int128[] memory initialQ = new int128[](3);
initialQ[0] = ABDKMath64x64.fromUInt(1_000_000);
initialQ[1] = ABDKMath64x64.fromUInt(1_000_000);
initialQ[2] = ABDKMath64x64.fromUInt(1_000_000);
// Update the state's cached qInternal
_updateCachedQInternal(initialQ);
// Use small trade size for clear slippage measurement
int128 tradeSize = ABDKMath64x64.divu(5, 10_000); // 0.05% of pool
int128 tradeAmount0 = s.qInternal[0].mul(tradeSize);
int128 tradeAmount1 = s.qInternal[1].mul(tradeSize);
// Store original state to restore between tests
int128[] memory backupQ = new int128[](s.nAssets);
for (uint i = 0; i < s.nAssets; i++) {
backupQ[i] = s.qInternal[i];
}
// First direction: asset 0 -> asset 1
(int128 amountIn0to1, int128 amountOut0to1) = s.swapAmountsForExactInput(0, 1, tradeAmount0, 0);
// Restore original state
_updateCachedQInternal(backupQ);
// Second direction: asset 1 -> asset 0
(int128 amountIn1to0, int128 amountOut1to0) = s.swapAmountsForExactInput(1, 0, tradeAmount1, 0);
console2.log("0->1 swap amountIn:");
console2.logInt(amountIn0to1);
console2.log("0->1 swap amountOut:");
console2.logInt(amountOut0to1);
console2.log("1->0 swap amountIn:");
console2.logInt(amountIn1to0);
console2.log("1->0 swap amountOut:");
console2.logInt(amountOut1to0);
// For balanced pools, the swap ratios should be approximately symmetric
int128 ratio0to1 = amountOut0to1.div(amountIn0to1);
int128 ratio1to0 = amountOut1to0.div(amountIn1to0);
// Calculate relative difference between the ratios
int128 ratioDifference = (ratio0to1.sub(ratio1to0)).abs();
int128 relativeRatioDiff = ratioDifference.div(ratio0to1.add(ratio1to0).div(ABDKMath64x64.fromInt(2)));
console2.log("Relative ratio difference (micro-units):");
console2.logInt(_toMicro(relativeRatioDiff));
// Assert that the relative difference between ratios is small
int128 tolerance = ABDKMath64x64.divu(5, 100); // 5% tolerance
assertLt(relativeRatioDiff, tolerance,
"Swap ratios should be approximately equal in both directions");
}
/// @notice Test that basic swap functionality works across multiple operations
function testZConsistencyAfterMultipleSwaps() public {
// Initialize with balanced assets
initBalanced();
// Create mock qInternal that we'll update through swaps
int128[] memory initialQ = new int128[](3);
initialQ[0] = ABDKMath64x64.fromUInt(1_000_000);
initialQ[1] = ABDKMath64x64.fromUInt(1_000_000);
initialQ[2] = ABDKMath64x64.fromUInt(1_000_000);
// Update the state's cached qInternal
_updateCachedQInternal(initialQ);
// Perform multiple swaps in different directions
for (uint i = 0; i < 5; i++) {
// Swap from asset i%3 to asset (i+1)%3
uint from = i % 3;
uint to = (i + 1) % 3;
int128 tradeAmount = s.qInternal[from].mul(stdTradeSize);
(int128 amountIn, int128 amountOut) = s.swapAmountsForExactInput(from, to, tradeAmount, 0);
// Apply swap to update internal state
s.applySwap(from, to, amountIn, amountOut);
// Basic validation that swap worked
assertTrue(amountIn > 0, "amountIn should be positive");
assertTrue(amountOut > 0, "amountOut should be positive");
}
}
// --- New tests for single-token mint/burn helpers ---
/// @notice Basic sanity check for swapAmountsForMint: small single-token input
function testSwapAmountsForMintBasic() public {
initBalanced();
// Use a small single-token input (stdTradeSize fraction of asset 0)
int128 a = s.qInternal[0].mul(stdTradeSize);
(int128 consumed, int128 lpIncrease) = s.swapAmountsForMint(0, a);
// consumed must be non-negative and <= provided a (partial-fill allowed)
assertTrue(consumed > 0, "consumed should be positive");
assertTrue(consumed <= a, "consumed should not exceed provided input");
// lpIncrease should be positive
assertTrue(lpIncrease > 0, "lpIncrease should be positive");
}
/// @notice Large input for swapAmountsForMint should return a valid partial fill (consumed <= provided)
function testSwapAmountsForMintLargeInputPartial() public {
initAlmostBalanced();
// Provide a large input far above stdTradeSize to exercise cap logic
int128 a = s.qInternal[0].mul(ABDKMath64x64.fromUInt(1000)); // 1000x one-asset quantity
(int128 consumed, int128 lpIncrease) = s.swapAmountsForMint(0, a);
// Should not consume more than provided
assertTrue(consumed <= a, "consumed must be <= provided");
// If nothing could be consumed, the helper should revert earlier; otherwise positive
assertTrue(consumed > 0, "consumed should be positive for large input in normal pools");
assertTrue(lpIncrease > 0, "lpIncrease should be positive for large input");
}
/// @notice Basic swapAmountsForBurn sanity: small alpha should return positive single-asset payout
function testSwapAmountsForBurnBasic() public {
initBalanced();
// Burn alpha fraction of pool
int128 alpha = ABDKMath64x64.divu(1, 100); // 1%
int128 S = _computeSizeMetric(s.qInternal);
(int128 payout, int128 burned) = s.swapAmountsForBurn(0, alpha);
// burned should equal alpha * S
assertEq(burned, alpha.mul(S), "burned size-metric mismatch");
// payout should be positive
assertTrue(payout > 0, "payout must be positive for balanced pool burn");
}
/// @notice If some assets have zero quantity, burn should skip them but still return payout when possible
function testSwapAmountsForBurnWithZeroAsset() public {
initBalanced();
// Make asset 1 empty; others non-zero
int128[] memory mockQInternal = new int128[](3);
mockQInternal[0] = ABDKMath64x64.fromUInt(1_000_000);
mockQInternal[1] = int128(0); // zero
mockQInternal[2] = ABDKMath64x64.fromUInt(1_000_000);
_updateCachedQInternal(mockQInternal);
int128 alpha = ABDKMath64x64.divu(1, 100); // 1%
(int128 payout, int128 burned) = s.swapAmountsForBurn(0, alpha);
// Should still burn the size metric
int128 S = _computeSizeMetric(mockQInternal);
assertEq(burned, alpha.mul(S), "burned size-metric mismatch with zero asset present");
// Payout should be at least the direct redeemed portion (alpha * q_i)
assertTrue(payout >= alpha.mul(mockQInternal[0]), "payout should be >= direct redeemed portion");
// Payout must be positive
assertTrue(payout > 0, "payout must be positive even when one asset is zero");
}
}

14
test/MockERC20.sol Normal file
View File

@@ -0,0 +1,14 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.30;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockERC20 is ERC20 {
uint8 private immutable _decimals;
constructor(string memory name, string memory symbol, uint8 decimals_) ERC20(name, symbol) {_decimals = decimals_;}
function decimals() public view virtual override returns (uint8) {return _decimals;}
function mint(address account, uint256 amount) external {_mint(account, amount);}
function burn(address account, uint256 amount) external {_burn(account, amount);}
}

1423
test/PartyPool.t.sol Normal file

File diff suppressed because it is too large Load Diff