From 7ac4cdc8f66184060156eaebc2205a850e5d8a67 Mon Sep 17 00:00:00 2001 From: tim Date: Tue, 14 Oct 2025 21:13:38 -0400 Subject: [PATCH] native currency fixes --- src/PartyPool.sol | 2 + test/Deploy.sol | 18 ++ test/NativeTest.t.sol | 612 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 632 insertions(+) create mode 100644 test/NativeTest.t.sol diff --git a/src/PartyPool.sol b/src/PartyPool.sol index 877cf8b..431a0d9 100644 --- a/src/PartyPool.sol +++ b/src/PartyPool.sol @@ -37,6 +37,8 @@ contract PartyPool is PartyPoolBase, ERC20External, IPartyPool { using LMSRStabilized for LMSRStabilized.State; using SafeERC20 for IERC20; + receive() external payable {} + function wrapperToken() external view returns (IWETH9) { return WRAPPER_TOKEN; } /// @notice Liquidity parameter κ (Q64.64) used by the LMSR kernel: b = κ * S(q) diff --git a/test/Deploy.sol b/test/Deploy.sol index 1504dde..e10d5b3 100644 --- a/test/Deploy.sol +++ b/test/Deploy.sol @@ -18,6 +18,10 @@ library Deploy { function newPartyPlanner() internal returns (PartyPlanner) { IWETH9 wrapper = new WETH9(); + return newPartyPlanner(wrapper); + } + + function newPartyPlanner(IWETH9 wrapper) internal returns (PartyPlanner) { return new PartyPlanner( wrapper, new PartyPoolSwapImpl(wrapper), @@ -40,6 +44,20 @@ library Deploy { bool _stable ) internal returns (PartyPool) { IWETH9 wrapper = new WETH9(); + return newPartyPool(name_, symbol_, tokens_, bases_, _kappa, _swapFeePpm, _flashFeePpm, wrapper, _stable); + } + + function newPartyPool( + string memory name_, + string memory symbol_, + IERC20[] memory tokens_, + uint256[] memory bases_, + int128 _kappa, + uint256 _swapFeePpm, + uint256 _flashFeePpm, + IWETH9 wrapper, + bool _stable + ) internal returns (PartyPool) { return _stable && tokens_.length == 2 ? new PartyPoolBalancedPair( name_, diff --git a/test/NativeTest.t.sol b/test/NativeTest.t.sol new file mode 100644 index 0000000..973bb60 --- /dev/null +++ b/test/NativeTest.t.sol @@ -0,0 +1,612 @@ +// SPDX-License-Identifier: UNLICENSED +/* solhint-disable */ +pragma solidity ^0.8.30; + +import "forge-std/Test.sol"; +import "@abdk/ABDKMath64x64.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "../src/LMSRStabilized.sol"; +import "../src/PartyPool.sol"; +import {IWETH9} from "../src/IWETH9.sol"; +import {PartyPlanner} from "../src/PartyPlanner.sol"; +import {Deploy} from "./Deploy.sol"; +import {PartyPoolViewer} from "../src/PartyPoolViewer.sol"; +import {WETH9} from "./WETH9.sol"; + +/// @notice Minimal ERC20 token for tests with an external mint function. +contract TestERC20Native is ERC20 { + constructor(string memory name_, string memory symbol_, uint256 initialSupply) ERC20(name_, symbol_) { + if (initialSupply > 0) { + _mint(msg.sender, initialSupply); + } + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + // Expose convenient approve helper for tests + function approveMax(address spender) external { + _approve(msg.sender, spender, type(uint256).max); + } +} + +/// @notice Tests for PartyPool native currency (ETH) functionality with WETH wrapping/unwrapping. +/// @dev This test contract creates a pool where one of the assets is WETH, then tests all operations +/// that can send or receive native currency by using unwrap=true and {value:amount} syntax. +contract NativeTest is Test { + using ABDKMath64x64 for int128; + + TestERC20Native token0; + TestERC20Native token1; + WETH9 weth; // WETH is our third token + PartyPool pool; + PartyPoolViewer viewer; + + address alice; + address bob; + + // Common parameters + int128 tradeFrac; + int128 targetSlippage; + + uint256 constant INIT_BAL = 1_000_000; // initial token units for each token + uint256 constant BASE = 1; // use base=1 so internal amounts correspond to raw integers + + function setUp() public { + alice = address(0xA11ce); + bob = address(0xB0b); + + // Give alice and bob native currency for testing + vm.deal(alice, 100 ether); + vm.deal(bob, 100 ether); + + // Deploy two regular ERC20 tokens + token0 = new TestERC20Native("T0", "T0", 0); + token1 = new TestERC20Native("T1", "T1", 0); + + // Deploy WETH + weth = new WETH9(); + + // Mint initial balances to this test contract + token0.mint(address(this), INIT_BAL); + token1.mint(address(this), INIT_BAL); + + // For WETH, we deposit native currency to get wrapped tokens + weth.deposit{value: INIT_BAL}(); + + // Configure LMSR parameters + tradeFrac = ABDKMath64x64.divu(100, 10_000); // 0.01 + targetSlippage = ABDKMath64x64.divu(10, 10_000); // 0.001 + + // Build arrays for pool constructor: [token0, token1, WETH] + IERC20[] memory tokens = new IERC20[](3); + tokens[0] = IERC20(address(token0)); + tokens[1] = IERC20(address(token1)); + tokens[2] = IERC20(address(weth)); // WETH as third token + + uint256[] memory bases = new uint256[](3); + bases[0] = BASE; + bases[1] = BASE; + bases[2] = BASE; + + // Deploy pool with a small fee (0.1%) + uint256 feePpm = 1000; + + int128 kappa = LMSRStabilized.computeKappaFromSlippage(tokens.length, tradeFrac, targetSlippage); + pool = Deploy.newPartyPool("LP", "LP", tokens, bases, kappa, feePpm, feePpm, weth, false); + + // Transfer initial deposit amounts into pool + token0.transfer(address(pool), INIT_BAL); + token1.transfer(address(pool), INIT_BAL); + weth.transfer(address(pool), INIT_BAL); + + // Perform initial mint + pool.initialMint(address(this), 0); + + // Mint tokens to alice and bob for testing + token0.mint(alice, INIT_BAL); + token1.mint(alice, INIT_BAL); + + token0.mint(bob, INIT_BAL); + token1.mint(bob, INIT_BAL); + + viewer = Deploy.newViewer(); + } + + /// @notice Helper to verify refunds work correctly + modifier expectRefund(address user, uint256 sent, uint256 expectedUsed) { + uint256 balBefore = user.balance; + _; + uint256 balAfter = user.balance; + uint256 refund = sent - expectedUsed; + assertEq(balAfter, balBefore - expectedUsed, "User should be refunded unused native currency"); + } + + /* ---------------------- + Swap Tests with Native Currency + ---------------------- */ + + /// @notice Test swap with native currency as input (token index 2 = WETH) + /// @dev Send ETH to pool, which should wrap it as WETH and execute the swap + function testSwapWithNativeInput() public { + uint256 maxIn = 10_000; + + // Alice swaps native currency (ETH -> WETH input) for token0 output + vm.startPrank(alice); + + uint256 aliceEthBefore = alice.balance; + uint256 aliceToken0Before = token0.balanceOf(alice); + + // Execute swap: WETH (index 2) -> token0 (index 0) + // Send native currency with {value: maxIn} + (uint256 amountIn, uint256 amountOut, uint256 fee) = pool.swap{value: maxIn}( + alice, // payer + alice, // receiver + 2, // inputTokenIndex (WETH) + 0, // outputTokenIndex (token0) + maxIn, // maxAmountIn + 0, // limitPrice + 0, // deadline + false // unwrap (output is not WETH, so false) + ); + + // Verify amounts + assertTrue(amountIn > 0, "expected some input used"); + assertTrue(amountOut > 0, "expected some output returned"); + assertTrue(amountIn <= maxIn, "used input must not exceed max"); + + // Alice's ETH balance should decrease by amountIn + assertEq(alice.balance, aliceEthBefore - amountIn, "Alice ETH should decrease by amountIn"); + + // Alice's token0 balance should increase by amountOut + assertEq(token0.balanceOf(alice), aliceToken0Before + amountOut, "Alice token0 should increase"); + + vm.stopPrank(); + } + + /// @notice Test swap with native currency as output (unwrap=true) + /// @dev Swap token0 for WETH, then unwrap WETH to native currency + function testSwapWithNativeOutput() public { + uint256 maxIn = 10_000; + + vm.startPrank(alice); + token0.approve(address(pool), type(uint256).max); + + uint256 aliceToken0Before = token0.balanceOf(alice); + uint256 aliceEthBefore = alice.balance; + + // Execute swap: token0 (index 0) -> WETH (index 2) with unwrap=true + (uint256 amountIn, uint256 amountOut, uint256 fee) = pool.swap( + alice, // payer + alice, // receiver + 0, // inputTokenIndex (token0) + 2, // outputTokenIndex (WETH) + maxIn, // maxAmountIn + 0, // limitPrice + 0, // deadline + true // unwrap (receive native currency instead of WETH) + ); + + // Verify amounts + assertTrue(amountIn > 0, "expected some input used"); + assertTrue(amountOut > 0, "expected some output returned"); + + // Alice's token0 balance should decrease by amountIn + assertEq(token0.balanceOf(alice), aliceToken0Before - amountIn, "Alice token0 should decrease"); + + // Alice's ETH balance should increase by amountOut (unwrapped) + assertEq(alice.balance, aliceEthBefore + amountOut, "Alice ETH should increase by unwrapped amount"); + + vm.stopPrank(); + } + + /// @notice Test swap with excess native currency sent - verify refund + function testSwapWithExcessNativeRefunded() public { + uint256 maxIn = 10_000; + uint256 excessAmount = 5_000; + uint256 totalSent = maxIn + excessAmount; + + vm.startPrank(alice); + + uint256 aliceEthBefore = alice.balance; + + // Execute swap with excess native currency + (uint256 amountIn, uint256 amountOut, uint256 fee) = pool.swap{value: totalSent}( + alice, // payer + alice, // receiver + 2, // inputTokenIndex (WETH) + 0, // outputTokenIndex (token0) + maxIn, // maxAmountIn + 0, // limitPrice + 0, // deadline + false // unwrap + ); + + // Verify that only amountIn was used, and excess was refunded + assertTrue(amountIn <= maxIn, "used input must not exceed max"); + uint256 expectedRefund = totalSent - amountIn; + assertEq(alice.balance, aliceEthBefore - amountIn, "Alice should be refunded excess ETH"); + + vm.stopPrank(); + } + + /// @notice Test swapToLimit with native currency input + function testSwapToLimitWithNativeInput() public { + // Choose a limit price slightly above current (~1) + int128 limitPrice = ABDKMath64x64.fromInt(1).add(ABDKMath64x64.divu(1, 1000)); + + vm.startPrank(alice); + + uint256 aliceEthBefore = alice.balance; + + // Execute swapToLimit: WETH (index 2) -> token0 (index 0) + // Send a large amount of native currency; pool will only use what's needed + uint256 largeAmount = 100_000; + (uint256 amountInUsed, uint256 amountOut, uint256 fee) = pool.swapToLimit{value: largeAmount}( + alice, // payer + alice, // receiver + 2, // inputTokenIndex (WETH) + 0, // outputTokenIndex (token0) + limitPrice, // limitPrice + 0, // deadline + false // unwrap + ); + + assertTrue(amountInUsed > 0, "expected some input used for swapToLimit"); + assertTrue(amountOut > 0, "expected some output for swapToLimit"); + + // Alice should be refunded unused ETH + assertTrue(alice.balance > aliceEthBefore - largeAmount, "Alice should be refunded"); + assertEq(alice.balance, aliceEthBefore - amountInUsed - fee, "Alice ETH balance check"); + + vm.stopPrank(); + } + + /// @notice Test swapToLimit with native currency output (unwrap=true) + function testSwapToLimitWithNativeOutput() public { + int128 limitPrice = ABDKMath64x64.fromInt(1).add(ABDKMath64x64.divu(1, 1000)); + + vm.startPrank(alice); + token0.approve(address(pool), type(uint256).max); + + uint256 aliceEthBefore = alice.balance; + + // Execute swapToLimit: token0 (index 0) -> WETH (index 2) with unwrap=true + (uint256 amountInUsed, uint256 amountOut, uint256 fee) = pool.swapToLimit( + alice, // payer + alice, // receiver + 0, // inputTokenIndex (token0) + 2, // outputTokenIndex (WETH) + limitPrice, // limitPrice + 0, // deadline + true // unwrap (receive native currency) + ); + + assertTrue(amountInUsed > 0, "expected some input used"); + assertTrue(amountOut > 0, "expected some output"); + + // Alice should receive native currency + assertTrue(alice.balance > aliceEthBefore, "Alice should receive ETH"); + + vm.stopPrank(); + } + + /* ---------------------- + Mint Tests with Native Currency + ---------------------- */ + + /// @notice Test proportional mint with native currency input + function testMintWithNativeInput() public { + uint256 lpRequest = pool.totalSupply() / 10; // Request 10% of pool + + // Get required deposit amounts + uint256[] memory deposits = viewer.mintAmounts(pool, lpRequest); + + vm.startPrank(alice); + token0.approve(address(pool), type(uint256).max); + token1.approve(address(pool), type(uint256).max); + // For WETH, we send native currency instead of approving + + uint256 aliceEthBefore = alice.balance; + uint256 wethDeposit = deposits[2]; // WETH is index 2 + + // Perform mint with native currency for WETH portion + uint256 lpMinted = pool.mint{value: wethDeposit}( + alice, // payer + alice, // receiver + lpRequest, // lpTokenAmount + 0 // deadline + ); + + assertTrue(lpMinted > 0, "LP should be minted"); + + // Alice's ETH should decrease by WETH deposit amount + assertEq(alice.balance, aliceEthBefore - wethDeposit, "Alice ETH should decrease"); + + vm.stopPrank(); + } + + /// @notice Test mint with excess native currency - verify refund + function testMintWithExcessNativeRefunded() public { + uint256 lpRequest = pool.totalSupply() / 10; + uint256[] memory deposits = viewer.mintAmounts(pool, lpRequest); + + vm.startPrank(alice); + token0.approve(address(pool), type(uint256).max); + token1.approve(address(pool), type(uint256).max); + + uint256 aliceEthBefore = alice.balance; + uint256 wethDeposit = deposits[2]; + uint256 excess = 10_000; + uint256 totalSent = wethDeposit + excess; + + // Send excess native currency + uint256 lpMinted = pool.mint{value: totalSent}( + alice, + alice, + lpRequest, + 0 + ); + + assertTrue(lpMinted > 0, "LP should be minted"); + + // Alice should be refunded the excess + assertEq(alice.balance, aliceEthBefore - wethDeposit, "Alice should be refunded excess"); + + vm.stopPrank(); + } + + /* ---------------------- + Burn Tests with Native Currency + ---------------------- */ + + /// @notice Test burn with native currency output (unwrap=true) + function testBurnWithNativeOutput() public { + uint256 lpToBurn = pool.totalSupply() / 10; + + // Get expected withdraw amounts + uint256[] memory withdraws = viewer.burnAmounts(pool, lpToBurn); + + uint256 thisEthBefore = address(this).balance; + uint256 expectedWethWithdraw = withdraws[2]; // WETH is index 2 + + // Burn LP with unwrap=true to receive native currency for WETH portion + uint256[] memory actualWithdraws = pool.burn( + address(this), // payer (this contract holds LP from setUp) + address(this), // receiver + lpToBurn, // lpAmount + 0, // deadline + true // unwrap (receive native currency for WETH) + ); + + // Verify we received the expected amounts + assertEq(actualWithdraws[0], withdraws[0], "token0 withdraw amount"); + assertEq(actualWithdraws[1], withdraws[1], "token1 withdraw amount"); + assertEq(actualWithdraws[2], withdraws[2], "WETH withdraw amount"); + + // Verify we received native currency for WETH portion + assertEq(address(this).balance, thisEthBefore + expectedWethWithdraw, "Should receive ETH for WETH"); + } + + /// @notice Test burn to a different receiver with native output + function testBurnToReceiverWithNativeOutput() public { + uint256 lpToBurn = pool.totalSupply() / 10; + uint256[] memory withdraws = viewer.burnAmounts(pool, lpToBurn); + + uint256 bobEthBefore = bob.balance; + uint256 bobToken0Before = token0.balanceOf(bob); + uint256 bobToken1Before = token1.balanceOf(bob); + + // Burn LP and send to bob with unwrap=true + pool.burn( + address(this), // payer + bob, // receiver + lpToBurn, + 0, + true // unwrap + ); + + // Bob should receive tokens and native currency + assertEq(token0.balanceOf(bob), bobToken0Before + withdraws[0], "Bob token0"); + assertEq(token1.balanceOf(bob), bobToken1Before + withdraws[1], "Bob token1"); + assertEq(bob.balance, bobEthBefore + withdraws[2], "Bob should receive ETH"); + } + + /* ---------------------- + SwapMint Tests with Native Currency + ---------------------- */ + + /// @notice Test swapMint with native currency input + function testSwapMintWithNativeInput() public { + uint256 maxIn = 10_000; + + vm.startPrank(alice); + + uint256 aliceEthBefore = alice.balance; + uint256 aliceLpBefore = pool.balanceOf(alice); + + // Call swapMint with native currency: deposit ETH as WETH (index 2) + uint256 lpMinted = pool.swapMint{value: maxIn}( + alice, // payer + alice, // receiver + 2, // inputTokenIndex (WETH) + maxIn, // maxAmountIn + 0 // deadline + ); + + assertTrue(lpMinted > 0, "swapMint should mint LP"); + + // Alice's ETH should decrease (by at most maxIn) + assertTrue(alice.balance <= aliceEthBefore, "Alice ETH should decrease"); + assertTrue(aliceEthBefore - alice.balance <= maxIn, "Alice spent at most maxIn"); + + // Alice should receive LP tokens + assertTrue(pool.balanceOf(alice) >= aliceLpBefore + lpMinted, "Alice should receive LP"); + + vm.stopPrank(); + } + + /// @notice Test swapMint with excess native currency - verify refund + function testSwapMintWithExcessNativeRefunded() public { + uint256 maxIn = 10_000; + uint256 excess = 20_000; + uint256 totalSent = maxIn + excess; + + vm.startPrank(alice); + + uint256 aliceEthBefore = alice.balance; + + // Send excess native currency + uint256 lpMinted = pool.swapMint{value: totalSent}( + alice, + alice, + 2, // WETH + maxIn, + 0 + ); + + assertTrue(lpMinted > 0, "swapMint should mint LP"); + + // Alice should not lose more than maxIn + assertTrue(aliceEthBefore - alice.balance <= maxIn, "Alice should be refunded excess"); + + vm.stopPrank(); + } + + /* ---------------------- + BurnSwap Tests with Native Currency + ---------------------- */ + + /// @notice Test burnSwap with native currency output (unwrap=true) + function testBurnSwapWithNativeOutput() public { + uint256 lpToBurn = pool.totalSupply() / 10; + + uint256 thisEthBefore = address(this).balance; + + // Burn LP and receive all proceeds as native currency (WETH unwrapped) + uint256 payout = pool.burnSwap( + address(this), // payer (holds LP) + address(this), // receiver + lpToBurn, // lpAmount + 2, // inputTokenIndex (WETH) + 0, // deadline + true // unwrap (receive native currency) + ); + + assertTrue(payout > 0, "burnSwap should produce payout"); + + // This contract should receive native currency + assertEq(address(this).balance, thisEthBefore + payout, "Should receive ETH"); + } + + /// @notice Test burnSwap to different receiver with native output + function testBurnSwapToReceiverWithNativeOutput() public { + uint256 lpToBurn = pool.totalSupply() / 10; + + uint256 bobEthBefore = bob.balance; + + // Burn LP and send native currency to bob + uint256 payout = pool.burnSwap( + address(this), // payer + bob, // receiver + lpToBurn, + 2, // WETH + 0, + true // unwrap + ); + + assertTrue(payout > 0, "burnSwap should produce payout"); + + // Bob should receive native currency + assertEq(bob.balance, bobEthBefore + payout, "Bob should receive ETH"); + } + + /* ---------------------- + Combined Native Operations + ---------------------- */ + + /// @notice Test full cycle: mint with native -> swap with native -> burn with native + function testFullCycleWithNative() public { + vm.startPrank(alice); + + // 1. Mint with native currency + uint256 lpRequest = pool.totalSupply() / 20; // 5% of pool + uint256[] memory deposits = viewer.mintAmounts(pool, lpRequest); + + token0.approve(address(pool), type(uint256).max); + token1.approve(address(pool), type(uint256).max); + + uint256 aliceEthStart = alice.balance; + + uint256 lpMinted = pool.mint{value: deposits[2]}(alice, alice, lpRequest, 0); + assertTrue(lpMinted > 0, "Should mint LP"); + + // 2. Swap native currency for token0 + uint256 swapAmount = 5_000; + (uint256 amountIn, uint256 amountOut, ) = pool.swap{value: swapAmount}( + alice, alice, 2, 0, swapAmount, 0, 0, false + ); + assertTrue(amountOut > 0, "Should receive token0"); + + // 3. Swap token0 back to native currency + uint256 token0Balance = token0.balanceOf(alice); + (uint256 swapIn2, uint256 swapOut2, ) = pool.swap( + alice, alice, 0, 2, token0Balance / 2, 0, 0, true + ); + assertTrue(swapOut2 > 0, "Should receive native currency"); + + // 4. Burn LP to native currency + uint256 lpToBurn = lpMinted / 2; + uint256 payout = pool.burnSwap(alice, alice, lpToBurn, 2, 0, true); + assertTrue(payout > 0, "Should receive payout in native"); + + // Alice should have some ETH back (maybe more or less depending on slippage) + assertTrue(alice.balance > 0, "Alice should have some ETH"); + + vm.stopPrank(); + } + + /// @notice Test that unwrap=false with WETH actually transfers WETH tokens (not native) + function testSwapWithWethNoUnwrap() public { + uint256 maxIn = 10_000; + + vm.startPrank(alice); + token0.approve(address(pool), type(uint256).max); + + uint256 aliceWethBefore = weth.balanceOf(alice); + uint256 aliceEthBefore = alice.balance; + + // Swap token0 -> WETH without unwrap + (uint256 amountIn, uint256 amountOut, ) = pool.swap( + alice, alice, 0, 2, maxIn, 0, 0, false // unwrap=false + ); + + assertTrue(amountOut > 0, "Should receive WETH tokens"); + + // Alice's WETH balance should increase + assertEq(weth.balanceOf(alice), aliceWethBefore + amountOut, "Alice should receive WETH tokens"); + + // Alice's ETH balance should not change (except for gas, but we don't track that) + assertEq(alice.balance, aliceEthBefore, "Alice ETH should not change with unwrap=false"); + + vm.stopPrank(); + } + + /// @notice Verify that sending native currency for non-WETH input reverts + function testSwapNativeForNonWethReverts() public { + vm.startPrank(alice); + + // Try to swap token0 (not WETH) by sending native currency - should revert + vm.expectRevert(); + pool.swap{value: 10_000}( + alice, alice, 0, 1, 10_000, 0, 0, false + ); + + vm.stopPrank(); + } + + // Make this contract payable to receive native currency from pool + receive() external payable {} +} +/* solhint-enable */