Liquidity Party execution

This commit is contained in:
tim
2025-12-10 14:47:04 -04:00
parent 4b69a0b6bc
commit 5c930b1187
6 changed files with 457 additions and 6 deletions

View File

@@ -2,7 +2,7 @@
src = 'src'
out = 'out'
libs = ['lib']
auto_detect_sol = true
auto_detect_solc = true
evm_version = 'cancun'
optimizer = true
optimizer_runs = 200
@@ -13,7 +13,7 @@ fs_permissions = [{ access = "read", path = "./test/assets" }, { access = "write
src = 'src'
out = 'out'
libs = ['lib']
auto_detect_sol = true
auto_detect_solc = true
evm_version = 'cancun'
optimizer = true
optimizer_runs = 1000

View File

@@ -47,10 +47,11 @@ contract RestrictTransferFrom {
permit2 = IAllowanceTransfer(_permit2);
}
// Hex enum values are also listed here, because they are used in test cases that check swap encodings.
enum TransferType {
TransferFrom,
Transfer,
None
TransferFrom, // uint8(0x00)
Transfer, // uint8(0x01)
None // uint8(0x02)
}
/**

View File

@@ -0,0 +1,139 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.30;
import {IExecutor} from "../../interfaces/IExecutor.sol";
import {RestrictTransferFrom} from "../RestrictTransferFrom.sol";
import {Initializable} from "../../lib/solady/src/utils/Initializable.sol";
contract LiquidityPartyExecutor is IExecutor, RestrictTransferFrom {
constructor(address _permit2) RestrictTransferFrom(_permit2) {}
/// @dev We avoid declaring any IERC20 types, since it is critical to use the router's transfer facility and never
/// the inherent ERC20 transfer methods, not even the SafeERC20 versions.
function swap(uint256 givenAmount, bytes calldata data)
external
payable
returns (uint256 calculatedAmount)
{
// Decode swap data
(
IPartyPool pool,
address tokenIn,
uint8 indexIn,
uint8 indexOut,
TransferType transferType,
address receiver
) = _decodeData(data);
// This require is redundant, since it is already checked in our DEX code.
// require(receiver != address(0), 'LiqP executor: No receiver');
// Pre-fund the pool with the input token
// NOTE: This approach only supports exact-in swaps that have no unused input.
// Since Tycho does not currently support user-approves-pool, we cannot use
// the preferred approach of giving allowances. Furthermore, the callback
// funding technique costs about 18,000 gas more than prefunding. Therefore,
// we prefund the pool with the desired max amount and allow there to be a
// small amount of unrefunded input dust if the full input is not used. In
// general, this will be cheaper than paying the additional gas for a refund
// of unused input.
_transfer(address(pool), transferType, tokenIn, givenAmount);
// Perform the swap
// slither-disable-next-line unused-return
(
// We ignore the actual amount in and allow there to be input dust.
/*uint256 amountIn*/,
uint256 amountOut,
/*uint256 inFee*/
) = pool.swap(
address(0), // payer address is unused if prefunding
Funding.PREFUNDING,
receiver,
indexIn,
indexOut,
givenAmount,
0, // no limit price
0, // no deadline
false, // no unwrap
"" // no callback data
);
// calculatedAmount is the net output amount
return amountOut;
}
function _decodeData(bytes calldata data)
internal
pure
returns (
IPartyPool pool,
address tokenIn,
uint8 indexIn,
uint8 indexOut,
TransferType transferType,
address receiver
)
{
// Do we really need this require? The decoding will revert if the length < 63 due to out-of-bounds array access
// It will also revert if the pool address is not a LiquidityParty IPartyPool instance
// It will also revert if the tokenIn address is not a token
// It will also revert if either indexIn or indexOut is greater than numTokens
// It will also revert if the transferType is not a valid enum
//require(data.length == 63, 'LiqP executor: Invalid encoding');
pool = IPartyPool(address(bytes20(data[0:20])));
tokenIn = address(bytes20(data[20:40]));
indexIn = uint8(data[40]);
indexOut = uint8(data[41]);
receiver = address(bytes20(data[42:62]));
transferType = TransferType(uint8(data[62]));
}
}
library Funding {
/// @notice a constant passed to swap as the fundingSelector to indicate that the payer has used regular ERC20 approvals to allow the pool to move the necessary input tokens.
// Slither analysis of this line is literally wrong and broken. The extra zero digits are REQUIRED by Solidity since it is a bytes4 literal.
// slither-disable-next-line too-many-digits
bytes4 internal constant APPROVALS = 0x00000000;
/// @notice a constant passed to swap as the fundingSelector to indicate that the payer has already sent sufficient input tokens to the pool before calling swap, so no movement of input tokens is required.
// Slither analysis of this line is literally wrong and broken. The extra zero digits are REQUIRED by Solidity since it is a bytes4 literal.
// slither-disable-next-line too-many-digits
bytes4 internal constant PREFUNDING = 0x00000001;
}
interface IPartyPool {
/// @notice Protocol fee ledger accessor. Returns tokens owed (raw uint token units) from this pool as protocol fees
/// that have not yet been transferred out.
function allProtocolFeesOwed() external view returns (uint256[] memory);
/// @notice Swap input token inputTokenIndex -> token outputTokenIndex. Payer must approve token inputTokenIndex.
/// @dev This function transfers the exact gross input (including fee) from payer and sends the computed output to receiver.
/// Non-standard tokens (fee-on-transfer, rebasers) are rejected via balance checks.
/// @param payer address of the account that pays for the swap
/// @param fundingSelector If set to USE_APPROVALS, then the payer must use regular ERC20 approvals to authorize the pool to move the required input amount. If this fundingSelector is USE_PREFUNDING, then all of the input amount is expected to have already been sent to the pool and no additional transfers are needed. Refunds of excess input amount are NOT provided and it is illegal to use this funding method with a limit price. Otherwise, for any other fundingSelector value, a callback style funding mechanism is used where the given selector is invoked on the payer, passing the arguments of (address inputToken, uint256 inputAmount). The callback function must send the given amount of input coin to the pool in order to continue the swap transaction, otherwise "Insufficient funds" is thrown.
/// @param receiver address that will receive the output tokens
/// @param inputTokenIndex index of input asset
/// @param outputTokenIndex index of output asset
/// @param maxAmountIn maximum amount of token inputTokenIndex (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.
/// @param cbData callback data if fundingSelector is of the callback type.
/// @return amountIn actual input used (uint256), amountOut actual output sent (uint256), inFee fee taken from the input (uint256)
function swap(
address payer,
bytes4 fundingSelector,
address receiver,
uint256 inputTokenIndex,
uint256 outputTokenIndex,
uint256 maxAmountIn,
int128 limitPrice,
uint256 deadline,
bool unwrap,
bytes memory cbData
)
external
payable
returns (uint256 amountIn, uint256 amountOut, uint256 inFee);
}

View File

@@ -0,0 +1,165 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.26;
import {
IERC20
} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import {
SafeERC20
} from "../../lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";
import {RestrictTransferFrom} from "../../src/RestrictTransferFrom.sol";
import {
LiquidityPartyExecutor,
IPartyPool
} from "../../src/executors/LiquidityPartyExecutor.sol";
import {Constants} from "../Constants.sol";
import {Permit2TestHelper} from "../Permit2TestHelper.sol";
import {TestUtils} from "../TestUtils.sol";
import {LiquidityPartyExecutorExposed} from "./LiquidityParty.t.sol";
contract LiquidityPartyExecutorExposed is LiquidityPartyExecutor {
constructor(address _permit2) LiquidityPartyExecutor(_permit2) {}
function decodeParams(bytes calldata data)
external
pure
returns (
IPartyPool pool,
address tokenIn,
uint8 indexIn,
uint8 indexOut,
TransferType transferType,
address receiver
)
{
return _decodeData(data);
}
}
contract LiquidityPartyExecutorTest is Constants, Permit2TestHelper, TestUtils {
using SafeERC20 for IERC20;
LiquidityPartyExecutorExposed private executor;
IPartyPool private constant POOL =
IPartyPool(0x2A804e94500AE379ee0CcC423a67B07cc0aF548C);
IERC20 private constant INPUT_TOKEN =
IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); // WETH
uint8 private constant INPUT_INDEX = 3;
uint256 private constant AMOUNT_IN = 30428379889; // 30 gwei, 0.1% of the pool's WETH
IERC20 private constant OUTPUT_TOKEN =
IERC20(0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE); // SHIB
uint8 private constant OUTPUT_INDEX = 9;
uint256 private constant EXPECTED_AMOUNT_OUT = 11480220066406156603; // about 115 SHIB
uint256 private constant FORK_BLOCK = 23978797;
function setUp() public {
vm.createSelectFork(vm.rpcUrl("mainnet"), FORK_BLOCK);
executor = new LiquidityPartyExecutorExposed(PERMIT2_ADDRESS);
}
function testDecodeParams() public view {
bytes memory params = abi.encodePacked(
POOL,
INPUT_TOKEN,
INPUT_INDEX,
OUTPUT_INDEX,
ALICE,
RestrictTransferFrom.TransferType.Transfer
);
(
IPartyPool pool,
address tokenIn,
uint8 indexIn,
uint8 indexOut,
RestrictTransferFrom.TransferType transferType,
address receiver
) = executor.decodeParams(params);
assertEq(address(pool), address(POOL));
assertEq(address(tokenIn), address(INPUT_TOKEN));
assertEq(indexIn, INPUT_INDEX);
assertEq(indexOut, OUTPUT_INDEX);
assertEq(
uint8(transferType),
uint8(RestrictTransferFrom.TransferType.Transfer)
);
assertEq(receiver, ALICE);
}
function testDecodeParamsInvalidDataLength() public {
bytes memory invalidParams =
abi.encodePacked(WETH_ADDR, address(2), address(3));
vm.expectRevert();
executor.decodeParams(invalidParams);
}
function testSwapWithTransfer() public {
bytes memory protocolData = abi.encodePacked(
POOL,
INPUT_TOKEN,
INPUT_INDEX,
OUTPUT_INDEX,
BOB,
RestrictTransferFrom.TransferType.Transfer
);
deal(address(INPUT_TOKEN), address(executor), AMOUNT_IN);
uint256 amountOut = executor.swap(AMOUNT_IN, protocolData);
assertEq(amountOut, EXPECTED_AMOUNT_OUT);
assertGe(OUTPUT_TOKEN.balanceOf(BOB), EXPECTED_AMOUNT_OUT);
}
function testSwapNoTransfer() public {
bytes memory protocolData = abi.encodePacked(
POOL,
INPUT_TOKEN,
INPUT_INDEX,
OUTPUT_INDEX,
BOB,
RestrictTransferFrom.TransferType.None
);
deal(address(INPUT_TOKEN), address(this), AMOUNT_IN);
/// forge-lint: disable-next-line(erc20-unchecked-transfer)
INPUT_TOKEN.transfer(address(POOL), AMOUNT_IN);
uint256 amountOut = executor.swap(AMOUNT_IN, protocolData);
assertEq(amountOut, EXPECTED_AMOUNT_OUT);
assertGe(OUTPUT_TOKEN.balanceOf(BOB), EXPECTED_AMOUNT_OUT);
}
function testSwapIntegration() public {
bytes memory protocolData =
loadCallDataFromFile("test_encode_liquidityparty");
deal(address(INPUT_TOKEN), address(executor), AMOUNT_IN);
uint256 amountOut = executor.swap(AMOUNT_IN, protocolData);
uint256 finalBalance = OUTPUT_TOKEN.balanceOf(BOB);
assertEq(amountOut, EXPECTED_AMOUNT_OUT);
assertGe(finalBalance, amountOut);
}
function testSwapFailureKilledPool() public {
// Killed pools should not even appear as protocol components, but we test an attempted swap anyway.
// This address is a pool that was killed (permanent redeem-only mode)
address killedPool = address(0xC0A908477FFeff658699182bEB5EcaF1D46B3ddB);
bytes memory protocolData = abi.encodePacked(
killedPool,
INPUT_TOKEN,
INPUT_INDEX,
OUTPUT_INDEX,
BOB,
RestrictTransferFrom.TransferType.None
);
deal(address(INPUT_TOKEN), address(executor), AMOUNT_IN);
vm.expectRevert();
executor.swap(AMOUNT_IN, protocolData);
}
function testExportContract() public {
exportRuntimeBytecode(address(executor), "LiquidityParty");
}
}