balanced pair optimization

This commit is contained in:
tim
2025-09-18 22:16:01 -04:00
parent a96b494cef
commit 3eba6412a6
7 changed files with 549 additions and 27 deletions

View File

@@ -162,7 +162,42 @@ contract GasTest is Test {
// Deploy pool with a small fee to test fee-handling paths (use 1000 ppm = 0.1%)
uint256 feePpm = 1000;
string memory poolName = string(abi.encodePacked("LP", vm.toString(numTokens)));
PartyPool newPool = new PartyPool(poolName, poolName, tokens, bases, tradeFrac, targetSlippage, feePpm, feePpm);
PartyPool newPool = new PartyPool(poolName, poolName, tokens, bases, tradeFrac, targetSlippage, feePpm, feePpm, false);
// Transfer initial deposit amounts into pool before initial mint
for (uint256 i = 0; i < numTokens; i++) {
TestERC20(tokens[i]).transfer(address(newPool), INIT_BAL);
}
// Perform initial mint (initial deposit); receiver is this contract
newPool.mint(address(0), address(this), 0, 0);
return newPool;
}
/// @notice Helper to create a pool with the stable-pair optimization enabled
function createPoolStable(uint256 numTokens) internal returns (PartyPool) {
// Deploy tokens dynamically
address[] memory tokens = new address[](numTokens);
uint256[] memory bases = new uint256[](numTokens);
for (uint256 i = 0; i < numTokens; i++) {
string memory name = string(abi.encodePacked("T", vm.toString(i)));
TestERC20 token = new TestERC20(name, name, 0);
tokens[i] = address(token);
bases[i] = BASE;
// Mint initial balances for pool initialization and test users
token.mint(address(this), INIT_BAL);
token.mint(alice, INIT_BAL);
token.mint(bob, INIT_BAL);
}
// Deploy pool with a small fee to test fee-handling paths (use 1000 ppm = 0.1%)
uint256 feePpm = 1000;
string memory poolName = string(abi.encodePacked("LPs", vm.toString(numTokens)));
// Note the final 'true' arg to activate stable-pair optimization path
PartyPool newPool = new PartyPool(poolName, poolName, tokens, bases, tradeFrac, targetSlippage, feePpm, feePpm, true);
// Transfer initial deposit amounts into pool before initial mint
for (uint256 i = 0; i < numTokens; i++) {
@@ -253,6 +288,24 @@ contract GasTest is Test {
_performSwapGasTest(pool50);
}
/// @notice Gas measurement: perform 10 swaps back-and-forth on a 2-token stable pair (stable-path enabled)
function testSwapGasStablePair() public {
PartyPool stablePair = createPoolStable(2);
_performSwapGasTest(stablePair);
}
/// @notice Gas-style test: alternate swapMint then burnSwap on a 2-token stable pair
function testSwapMintBurnSwapGasStablePair() public {
PartyPool stablePair = createPoolStable(2);
_performSwapMintBurnSwapGasTest(stablePair);
}
/// @notice Combined gas test (mint then burn) on 2-token stable pair using mint() and burn().
function testMintBurnGasStablePair() public {
PartyPool stablePair = createPoolStable(2);
_performMintBurnGasTest(stablePair);
}
/// @notice Helper function: alternate swapMint then burnSwap to keep pool size roughly stable.
function _performSwapMintBurnSwapGasTest(PartyPool testPool) internal {
uint256 iterations = 10;

View File

@@ -5,6 +5,7 @@ import "forge-std/Test.sol";
import "forge-std/console.sol";
import "@openzeppelin/contracts/interfaces/IERC20Metadata.sol";
import "../src/LMSRStabilized.sol";
import "../src/LMSRStabilizedBalancedPair.sol";
/// @notice Forge tests for LMSRStabilized
@@ -253,6 +254,96 @@ contract LMSRStabilizedTest is Test {
assertLt(relativeError, ABDKMath64x64.divu(1, 100), "Slippage target not met after deposit");
}
/// @notice Test balanced2 handling of limitPrice that causes truncation of input a
function testBalanced2LimitTruncation() public {
// Two-asset balanced pool
int128[] memory q = new int128[](2);
q[0] = ABDKMath64x64.fromUInt(1_000_000);
q[1] = ABDKMath64x64.fromUInt(1_000_000);
s.init(q, stdTradeSize, stdSlippage);
// Compute b for constructing meaningful a and limits
int128 b = _computeB(q);
// Choose a large requested input so that the limitPrice will truncate it.
int128 aRequested = b.mul(ABDKMath64x64.fromUInt(10)); // a/b = 10 (within EXP_LIMIT)
// Small limit slightly above current price (r0 == 1). Use 0.1% above parity.
int128 limitPrice = ABDKMath64x64.fromInt(1).add(ABDKMath64x64.divu(1, 1000)); // 1.001
// Call optimized balanced2 and exact versions
(int128 inApprox, int128 outApprox) = LMSRStabilizedBalancedPair.swapAmountsForExactInput(s, 0, 1, aRequested, limitPrice);
(int128 inExact, int128 outExact) = s.swapAmountsForExactInput(0, 1, aRequested, limitPrice);
// Ensure exact returned something sensible
assertTrue(outExact > 0, "exact output should be positive");
// Relative error tolerance 0.001% (1e-5)
int128 relErr = (outApprox.sub(outExact)).abs().div(outExact);
int128 tol = ABDKMath64x64.divu(1, 100_000);
assertLt(relErr, tol, "balanced2 truncated output deviates from exact beyond tolerance");
// Input used should be close as well
int128 inRelErr = (inApprox.sub(inExact)).abs();
// If exact truncated, inExact likely equals aLimit computed by ln; allow small absolute difference tolerance of 1e-6 relative to b
int128 absTol = b.div(ABDKMath64x64.fromUInt(1_000_000)); // b * 1e-6
assertTrue(inRelErr <= absTol, "balanced2 truncated input differs from exact beyond small absolute tolerance");
}
/// @notice Test balanced2 with a limitPrice that does not truncate the provided input
function testBalanced2LimitNoTruncation() public {
// Two-asset balanced pool
int128[] memory q = new int128[](2);
q[0] = ABDKMath64x64.fromUInt(1_000_000);
q[1] = ABDKMath64x64.fromUInt(1_000_000);
s.init(q, stdTradeSize, stdSlippage);
// Small input a
int128 a = q[0].mul(ABDKMath64x64.divu(1, 1000)); // 0.1% of asset
// Very relaxed limit (2x current price) which should not truncate
int128 limitPrice = ABDKMath64x64.fromUInt(2);
(int128 inApprox, int128 outApprox) = LMSRStabilizedBalancedPair.swapAmountsForExactInput(s, 0, 1, a, limitPrice);
(int128 inExact, int128 outExact) = s.swapAmountsForExactInput(0, 1, a, limitPrice);
// Exact outputs must be positive
assertTrue(outExact > 0, "exact output should be positive");
// Expect almost exact match when no truncation occurs; use tight tolerance
int128 relErr = (outApprox.sub(outExact)).abs().div(outExact);
int128 tol = ABDKMath64x64.divu(1, 100_000); // 0.001%
assertLt(relErr, tol, "balanced2 no-truncate output deviates from exact beyond tolerance");
// AmountIn should equal provided a for both functions
assertEq(inApprox, a, "balanced2 should use full input when not truncated");
assertEq(inExact, a, "exact should use full input when not truncated");
}
/// @notice Test that balanced2 reverts when limitPrice <= current price (no partial fill allowed)
function testBalanced2LimitRevertWhenAtOrBelowCurrent() public {
// Two-asset balanced pool
int128[] memory q = new int128[](2);
q[0] = ABDKMath64x64.fromUInt(1_000_000);
q[1] = ABDKMath64x64.fromUInt(1_000_000);
s.init(q, stdTradeSize, stdSlippage);
int128 limitPrice = ABDKMath64x64.fromInt(1); // equal to current price
vm.expectRevert(bytes("LMSR: limitPrice <= current price"));
this._swapAmountsForExactInput_balanced2(0, 1, q[0].mul(ABDKMath64x64.divu(1, 1000)), limitPrice);
}
function _swapAmountsForExactInput_balanced2(
uint256 i,
uint256 j,
int128 a,
int128 limitPrice
) external view returns (int128 amountIn, int128 amountOut) {
return LMSRStabilizedBalancedPair.swapAmountsForExactInput(s, i,j,a,limitPrice);
}
function testRescalingAfterWithdrawal() public {
// Initialize pool with almost balanced assets
initAlmostBalanced();
@@ -858,4 +949,116 @@ contract LMSRStabilizedTest is Test {
assertTrue(payout > 0, "payout must be positive even when one asset is zero");
}
/// @notice Test that the balanced2 polynomial approximation is accurate for a two-asset balanced pool
function testBalanced2ApproxAccuracy() public {
// Create a minimal two-asset balanced pool
int128[] memory q = new int128[](2);
q[0] = ABDKMath64x64.fromUInt(1_000_000);
q[1] = ABDKMath64x64.fromUInt(1_000_000);
s.init(q, stdTradeSize, stdSlippage);
// Small trade (well within u <= 0.5 and delta <= 1%)
int128 a = q[0].mul(ABDKMath64x64.divu(1, 1000)); // 0.1% of asset
// Compute approx and exact
(int128 inApprox, int128 outApprox) = LMSRStabilizedBalancedPair.swapAmountsForExactInput(s, 0, 1, a, 0);
(int128 inExact, int128 outExact) = s.swapAmountsForExactInput(0, 1, a, 0);
// Sanity
assertTrue(outExact > 0, "Exact output should be positive");
// Relative error: |approx - exact| / exact
int128 relErr = (outApprox.sub(outExact)).abs().div(outExact);
// Require relative error < 0.001% (1e-5) => expressed as 1 / 100_000
int128 tolerance = ABDKMath64x64.divu(1, 100_000);
assertLt(relErr, tolerance, "balanced2 approximation relative error too large");
// AmountIn should equal requested a (no truncation)
assertEq(inApprox, a, "balanced2 approximation should use full input when no limitPrice");
assertEq(inExact, a, "exact computation should use full input when no limitPrice");
}
/// @notice Test that when the parity assumption is violated, the balanced2 helper falls back
/// to the exact implementation (we expect identical outputs).
function testBalanced2FallbackWhenParityViolated() public {
// Start with two-asset balanced pool (we'll mutate it)
int128[] memory q = new int128[](2);
q[0] = ABDKMath64x64.fromUInt(1_000_000);
q[1] = ABDKMath64x64.fromUInt(1_000_000);
s.init(q, stdTradeSize, stdSlippage);
// Prepare newQ starting from equal quantities; we'll grow q0 until delta > DELTA_MAX
int128[] memory newQ = new int128[](2);
newQ[0] = q[0];
newQ[1] = q[1];
// DELTA_MAX used by the library: 0.01
int128 DELTA_MAX = ABDKMath64x64.divu(1, 100);
// Iteratively increase q0 until the library's delta = (q0 - q1) / b > DELTA_MAX
// We cap iterations to avoid infinite loops in pathological cases.
bool reached = false;
for (uint iter = 0; iter < 64; iter++) {
// Update the state's cached qInternal with the candidate imbalance
_updateCachedQInternal(newQ);
// Compute the current b and delta using the state's parameters
int128 bNow = _computeB(); // uses s.qInternal and s.kappa
// avoid division by zero
if (bNow == int128(0)) { break; }
int128 deltaNow = newQ[0].sub(newQ[1]).div(bNow);
if (deltaNow < int128(0)) { deltaNow = deltaNow.neg(); }
if (deltaNow > DELTA_MAX) {
reached = true;
break;
}
// Increase q0 by 10% each iteration to quickly cross the threshold
newQ[0] = newQ[0].mul(ABDKMath64x64.fromUInt(11)).div(ABDKMath64x64.fromUInt(10));
}
// Ensure we actually achieved the desired imbalance for a meaningful test
_updateCachedQInternal(newQ);
int128 finalB = _computeB();
int128 finalDelta = newQ[0].sub(newQ[1]).div(finalB);
if (finalDelta < int128(0)) finalDelta = finalDelta.neg();
assertTrue(finalDelta > DELTA_MAX, "failed to create delta > DELTA_MAX in test");
// Small trade amount
int128 a = newQ[0].mul(ABDKMath64x64.divu(1, 1000));
// Call both functions; balanced2 should detect parity violation and fall back to exact
(int128 inApprox, int128 outApprox) = LMSRStabilizedBalancedPair.swapAmountsForExactInput(s, 0, 1, a, 0);
(int128 inExact, int128 outExact) = s.swapAmountsForExactInput(0, 1, a, 0);
// Because parity assumption is violated balanced2 should fall back to exact implementation
assertEq(inApprox, inExact, "fallback should return identical amountIn");
assertEq(outApprox, outExact, "fallback should return identical amountOut");
}
/// @notice Test that the balanced2 helper falls back when scaled input u = a/b is too large
function testBalanced2FallbackOnLargeInput() public {
// Two-asset balanced pool
int128[] memory q = new int128[](2);
q[0] = ABDKMath64x64.fromUInt(1_000_000);
q[1] = ABDKMath64x64.fromUInt(1_000_000);
s.init(q, stdTradeSize, stdSlippage);
// Compute b
int128 b = _computeB(q);
// Choose a so that u = a / b = 0.75 (> 0.5 U_MAX)
int128 a = b.mul(ABDKMath64x64.divu(3, 4)); // a/b = 0.75
// Call both functions and expect fallback (identical results)
(int128 inApprox, int128 outApprox) = LMSRStabilizedBalancedPair.swapAmountsForExactInput(s, 0, 1, a, 0);
(int128 inExact, int128 outExact) = s.swapAmountsForExactInput(0, 1, a, 0);
assertEq(inApprox, inExact, "fallback on large input should return identical amountIn");
assertEq(outApprox, outExact, "fallback on large input should return identical amountOut");
}
}

View File

@@ -195,7 +195,7 @@ contract PartyPoolTest is Test {
// Deploy pool with a small fee to test fee-handling paths (use 1000 ppm = 0.1%)
uint256 feePpm = 1000;
pool = new PartyPool("LP", "LP", tokens, bases, tradeFrac, targetSlippage, feePpm, feePpm);
pool = new PartyPool("LP", "LP", tokens, bases, tradeFrac, targetSlippage, feePpm, feePpm, false);
// Transfer initial deposit amounts into pool before initial mint (pool expects tokens already in contract)
// We deposit equal amounts INIT_BAL for each token
@@ -224,7 +224,7 @@ contract PartyPoolTest is Test {
bases10[i] = BASE;
}
pool10 = new PartyPool("LP10", "LP10", tokens10, bases10, tradeFrac, targetSlippage, feePpm, feePpm);
pool10 = new PartyPool("LP10", "LP10", tokens10, bases10, tradeFrac, targetSlippage, feePpm, feePpm, false);
// Mint additional tokens for pool10 initial deposit
token0.mint(address(this), INIT_BAL);