Skip to content

Commit 6a7e2b7

Browse files
authored
feat: Add LP position deduplication to prevent double-counting (follow-up to #1398, fixes PRO-2169) (#1419)
* wip on fix liquidity * added a test that shows memory value mod
1 parent 7df9491 commit 6a7e2b7

File tree

4 files changed

+228
-4
lines changed

4 files changed

+228
-4
lines changed

.changeset/beige-hotels-laugh.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@zoralabs/coins": patch
3+
---
4+
5+
Fix LP position duplication when deploying coins. If two positions are created with the same tick ranges, they are merged and stored as one position. This reduces gas costs during swapping as there are less LP positions to iterate through when collecting fees.

packages/coins/src/hooks/ZoraV4CoinHook.sol

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,18 @@ contract ZoraV4CoinHook is
146146
/// @param coin The coin address.
147147
/// @param key The pool key for the coin.
148148
/// @return positions The contract-created liquidity positions the positions for the coin's pool.
149-
function _generatePositions(ICoin coin, PoolKey memory key) internal view returns (LpPosition[] memory positions) {
149+
function _generatePositions(ICoin coin, PoolKey memory key) internal view returns (LpPosition[] memory) {
150150
bool isCoinToken0 = Currency.unwrap(key.currency0) == address(coin);
151151

152-
positions = CoinDopplerMultiCurve.calculatePositions(isCoinToken0, coin.getPoolConfiguration(), coin.totalSupplyForPositions());
152+
LpPosition[] memory calculatedPositions = CoinDopplerMultiCurve.calculatePositions(
153+
isCoinToken0,
154+
coin.getPoolConfiguration(),
155+
coin.totalSupplyForPositions()
156+
);
157+
158+
// sometimes the calculated positions have liquidity added in duplicated positions. So here we dedupe them
159+
// to save on gas in future swaps.
160+
return V4Liquidity.dedupePositions(calculatedPositions);
153161
}
154162

155163
/// @notice Internal fn called when a pool is initialized.

packages/coins/src/libs/V4Liquidity.sol

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,6 @@ import {IUpgradeableDestinationV4Hook, IUpgradeableDestinationV4HookWithUpdateab
3232
import {LiquidityAmounts} from "../utils/uniswap/LiquidityAmounts.sol";
3333
import {IZoraV4CoinHook} from "../interfaces/IZoraV4CoinHook.sol";
3434

35-
import {console} from "forge-std/console.sol";
36-
3735
// command = 1; mint
3836
struct MintCallbackData {
3937
PoolKey poolKey;
@@ -158,6 +156,41 @@ library V4Liquidity {
158156
return abi.encode(result);
159157
}
160158

159+
function dedupePositions(LpPosition[] memory positions) internal pure returns (LpPosition[] memory dedupedPositions) {
160+
// Upper bound: no more than input length
161+
dedupedPositions = new LpPosition[](positions.length);
162+
uint outLen = 0;
163+
164+
// O(n²) approach: for each position, check if it already exists in output
165+
// This is acceptable since position arrays are typically small (< 100 positions)
166+
167+
for (uint i = 0; i < positions.length; i++) {
168+
int24 t0 = positions[i].tickLower;
169+
int24 t1 = positions[i].tickUpper;
170+
uint128 v = positions[i].liquidity;
171+
172+
bool duplicate = false;
173+
for (uint j = 0; j < outLen; j++) {
174+
LpPosition memory dedupedPosition = dedupedPositions[j];
175+
if (dedupedPosition.tickLower == t0 && dedupedPosition.tickUpper == t1) {
176+
dedupedPosition.liquidity += v;
177+
duplicate = true;
178+
break;
179+
}
180+
}
181+
182+
if (!duplicate) {
183+
dedupedPositions[outLen] = LpPosition({tickLower: t0, tickUpper: t1, liquidity: v});
184+
outLen++;
185+
}
186+
}
187+
188+
// Shrink to exact size by overwriting length field on the array
189+
assembly {
190+
mstore(dedupedPositions, outLen)
191+
}
192+
}
193+
161194
function generatePositionsFromMigratedLiquidity(
162195
uint160 sqrtPriceX96,
163196
BurnedPosition[] calldata migratedLiquidity
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.13;
3+
4+
import "./utils/BaseTest.sol";
5+
import {CoinConfigurationVersions} from "../src/libs/CoinConfigurationVersions.sol";
6+
import {CoinDopplerMultiCurve} from "../src/libs/CoinDopplerMultiCurve.sol";
7+
import {V4Liquidity} from "../src/libs/V4Liquidity.sol";
8+
import {LpPosition} from "../src/types/LpPosition.sol";
9+
import {PoolConfiguration} from "../src/interfaces/ICoin.sol";
10+
import {CoinConstants} from "../src/libs/CoinConstants.sol";
11+
import {MockERC20} from "./mocks/MockERC20.sol";
12+
import {ContentCoin} from "../src/ContentCoin.sol";
13+
import {ZoraV4CoinHook} from "../src/hooks/ZoraV4CoinHook.sol";
14+
import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
15+
16+
contract V4LiquidityTest is BaseTest {
17+
MockERC20 internal mockERC20A;
18+
19+
function setUp() public override {
20+
super.setUp();
21+
mockERC20A = new MockERC20("MockERC20A", "MCKA");
22+
}
23+
24+
function _poolConfigWithDuplicatePositions(address currency) private pure returns (bytes memory poolConfig) {
25+
// Create configuration that will produce duplicate positions
26+
int24[] memory tickLower_ = new int24[](2);
27+
tickLower_[0] = -54000;
28+
tickLower_[1] = -54000; // Same as first curve
29+
30+
int24[] memory tickUpper_ = new int24[](2);
31+
tickUpper_[0] = 7000;
32+
tickUpper_[1] = 7000; // Same as first curve
33+
34+
uint16[] memory numDiscoveryPositions_ = new uint16[](2);
35+
numDiscoveryPositions_[0] = 5;
36+
numDiscoveryPositions_[1] = 5;
37+
38+
uint256[] memory maxDiscoverySupplyShare_ = new uint256[](2);
39+
maxDiscoverySupplyShare_[0] = 100000000000000000; // 0.1e18
40+
maxDiscoverySupplyShare_[1] = 100000000000000000; // 0.1e18
41+
42+
poolConfig = CoinConfigurationVersions.encodeDopplerMultiCurveUniV4(currency, tickLower_, tickUpper_, numDiscoveryPositions_, maxDiscoverySupplyShare_);
43+
}
44+
45+
function _countDuplicatePositions(LpPosition[] memory positions) private pure returns (uint256 duplicateCount) {
46+
for (uint256 i = 0; i < positions.length; i++) {
47+
for (uint256 j = i + 1; j < positions.length; j++) {
48+
if (positions[i].tickLower == positions[j].tickLower && positions[i].tickUpper == positions[j].tickUpper) {
49+
duplicateCount++;
50+
break; // Only count each unique duplicate once
51+
}
52+
}
53+
}
54+
}
55+
56+
function test_calculatePositionsWithDuplicateConfigCreatesDuplicates() public view {
57+
address currency = address(mockERC20A);
58+
bytes memory poolConfig = _poolConfigWithDuplicatePositions(currency);
59+
60+
(, PoolConfiguration memory poolConfiguration) = CoinDopplerMultiCurve.setupPool(true, poolConfig);
61+
62+
LpPosition[] memory positions = CoinDopplerMultiCurve.calculatePositions(
63+
true, // isCoinToken0
64+
poolConfiguration,
65+
CoinConstants.CONTENT_COIN_MARKET_SUPPLY
66+
);
67+
68+
uint256 duplicateCount = _countDuplicatePositions(positions);
69+
assertGt(duplicateCount, 0, "Should have duplicate positions");
70+
}
71+
72+
function test_dedupePositionsMergesDuplicates() public view {
73+
address currency = address(mockERC20A);
74+
bytes memory poolConfig = _poolConfigWithDuplicatePositions(currency);
75+
76+
(, PoolConfiguration memory poolConfiguration) = CoinDopplerMultiCurve.setupPool(true, poolConfig);
77+
78+
LpPosition[] memory originalPositions = CoinDopplerMultiCurve.calculatePositions(
79+
true, // isCoinToken0
80+
poolConfiguration,
81+
CoinConstants.CONTENT_COIN_MARKET_SUPPLY
82+
);
83+
84+
uint256 originalDuplicateCount = _countDuplicatePositions(originalPositions);
85+
assertGt(originalDuplicateCount, 0, "Should have duplicate positions to test deduplication");
86+
87+
// Deduplicate the positions
88+
LpPosition[] memory dedupedPositions = V4Liquidity.dedupePositions(originalPositions);
89+
90+
// Verify no duplicates exist in deduped array
91+
uint256 dedupedDuplicateCount = _countDuplicatePositions(dedupedPositions);
92+
assertEq(dedupedDuplicateCount, 0, "Should have no duplicates after deduplication");
93+
94+
// Verify that array is smaller after deduplication
95+
assertLt(dedupedPositions.length, originalPositions.length, "Deduped array should be smaller");
96+
97+
// Calculate total liquidity before and after deduplication
98+
uint256 totalOriginalLiquidity = 0;
99+
uint256 totalDedupedLiquidity = 0;
100+
101+
for (uint256 i = 0; i < originalPositions.length; i++) {
102+
totalOriginalLiquidity += originalPositions[i].liquidity;
103+
}
104+
105+
for (uint256 i = 0; i < dedupedPositions.length; i++) {
106+
totalDedupedLiquidity += dedupedPositions[i].liquidity;
107+
}
108+
109+
assertEq(totalOriginalLiquidity, totalDedupedLiquidity, "Total liquidity should be preserved");
110+
}
111+
112+
function test_memoryStructModification() public pure {
113+
// this test shows that we can modify a struct in memory and it will be reflected in the array
114+
LpPosition[] memory positions = new LpPosition[](2);
115+
positions[0] = LpPosition({tickLower: -100, tickUpper: 100, liquidity: 1000});
116+
positions[1] = LpPosition({tickLower: -200, tickUpper: 200, liquidity: 2000});
117+
118+
LpPosition memory pos = positions[0];
119+
pos.liquidity += 500;
120+
pos = positions[1];
121+
pos.liquidity = 3000;
122+
123+
// The array element should be modified
124+
assertEq(positions[0].liquidity, 1500, "Array element should change when modifying copy");
125+
assertEq(positions[1].liquidity, 3000, "Array element should change when modifying copy");
126+
}
127+
128+
function test_mstoreArrayLength() public pure {
129+
LpPosition[] memory positions = new LpPosition[](5);
130+
positions[0] = LpPosition({tickLower: -100, tickUpper: 100, liquidity: 1000});
131+
positions[1] = LpPosition({tickLower: -200, tickUpper: 200, liquidity: 2000});
132+
positions[2] = LpPosition({tickLower: -300, tickUpper: 300, liquidity: 3000});
133+
positions[3] = LpPosition({tickLower: -400, tickUpper: 400, liquidity: 4000});
134+
positions[4] = LpPosition({tickLower: -500, tickUpper: 500, liquidity: 5000});
135+
136+
assertEq(positions.length, 5, "Initial length should be 5");
137+
138+
assembly {
139+
mstore(positions, 2)
140+
}
141+
142+
assertEq(positions.length, 2, "Length should be 2 after mstore");
143+
assertEq(positions[0].liquidity, 1000, "First element should be preserved");
144+
assertEq(positions[1].liquidity, 2000, "Second element should be preserved");
145+
}
146+
147+
function test_deployedCoinWithDuplicateConfigHasNoDuplicatePositions() public {
148+
address currency = address(mockERC20A);
149+
150+
address[] memory owners = new address[](1);
151+
owners[0] = users.creator;
152+
153+
bytes memory poolConfig = _poolConfigWithDuplicatePositions(currency);
154+
155+
(address coinAddress, ) = factory.deploy(
156+
users.creator,
157+
owners,
158+
"https://test.com",
159+
DEFAULT_NAME,
160+
DEFAULT_SYMBOL,
161+
poolConfig,
162+
address(0),
163+
address(0),
164+
bytes(""),
165+
bytes32(0)
166+
);
167+
168+
ContentCoin coinV4 = ContentCoin(payable(coinAddress));
169+
170+
// get hooks
171+
PoolKey memory poolKey = coinV4.getPoolKey();
172+
LpPosition[] memory positions = ZoraV4CoinHook(payable(address(coinV4.hooks()))).getPoolCoin(poolKey).positions;
173+
174+
// Verify no duplicate positions exist in the deployed coin (deduplication worked during deployment)
175+
uint256 duplicateCount = _countDuplicatePositions(positions);
176+
assertEq(duplicateCount, 0, "Should have no duplicates after deployment");
177+
}
178+
}

0 commit comments

Comments
 (0)