Files
lmsr-amm/test/LMSRStabilized.t.sol

1022 lines
44 KiB
Solidity

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/interfaces/IERC20Metadata.sol";
import "../src/LMSRStabilized.sol";
import "../src/LMSRStabilizedBalancedPair.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));
// 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));
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));
// 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);
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.qInternal.length > 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;
uint256 nAssets = s.qInternal.length;
// Simulate a deposit by increasing all asset quantities by 50%
int128[] memory newQ = new int128[](nAssets);
for (uint i = 0; i < 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));
int128 relativeError = slippage.sub(stdSlippage).abs().div(stdSlippage);
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();
// 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%
uint256 nAssets = s.qInternal.length;
int128[] memory newQ = new int128[](nAssets);
for (uint i = 0; i < 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));
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.qInternal.length > 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 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
uint256 nAssets = s.qInternal.length;
int128[] memory initialQValues = new int128[](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[](nAssets);
for (uint i = 0; i < 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
// 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);
// 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);
// 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
uint256 nAssets = s.qInternal.length;
int128[] memory backupQ = new int128[](nAssets);
for (uint i = 0; i < 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);
// 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)));
// 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 burned, int128 payout) = 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 burned, int128 payout) = 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");
}
/// @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");
}
}