Skip to content

Commit 74887cf

Browse files
authored
Merge pull request #97 from NFTX-project/migrator-zap-off-by-wei-fix
MigratorZap: avoid off by few wei when withdrawing from v2 Inventory
2 parents b3bcfb0 + f427b78 commit 74887cf

File tree

9 files changed

+1309
-112
lines changed

9 files changed

+1309
-112
lines changed

addresses.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"mainnet": {
33
"CreateVaultZap": "0x56dab32697B4A313f353DA0CE42B5113eD8E6f74",
44
"MarketplaceUniversalRouterZap": "0x293A0c49c85F1D8851C665Ac3cE1f1DC2a79bE3d",
5-
"MigratorZap": "0x946EcA3fD23778Ccf96f4E8D4d1EF114A56b3f51",
5+
"MigratorZap": "0x089610Fb04c34C014B4B391f4eCEFAef94E98CEc",
66
"NFTXFeeDistributorV3": "0xF4d96C5094FCD9eC24E612585e723b58F89e21fe",
77
"NFTXInventoryStakingV3Upgradeable": "0x889f313e2a3FDC1c9a45bC6020A8a18749CD6152",
88
"NFTXRouter": "0x70A741A12262d4b5Ff45C0179c783a380EebE42a",
@@ -36,7 +36,7 @@
3636
"sepolia": {
3737
"CreateVaultZap": "0xD80b916470F8e79FD8d09874cb159CbB8D13d8da",
3838
"MarketplaceUniversalRouterZap": "0xd88a3B9D0Fb2d39ec8394CfFD983aFBB2D4a6410",
39-
"MigratorZap": "0x802aaAE9395aA5f7F414B237D1bb13d4f58f43c7",
39+
"MigratorZap": "0x19762e505aF085284E287c8DAb931fb28545461f",
4040
"NFTXFeeDistributorV3": "0x66EF5B4b6ee05639194844CE4867515665F14fED",
4141
"NFTXInventoryStakingV3Upgradeable": "0xfBFf0635f7c5327FD138E1EBa72BD9877A6a7C1C",
4242
"NFTXRouter": "0x441b7DE4340AAa5aA86dB4DA43d9Badf7B2DAA66",

deploy/individual/MigratorZap.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { HardhatRuntimeEnvironment, Network } from "hardhat/types";
2+
import { DeployFunction } from "hardhat-deploy/types";
3+
import { utils } from "ethers";
4+
import deployConfig from "../../deployConfig";
5+
6+
const func: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
7+
const { deployments, getNamedAccounts, network } = hre;
8+
const { deploy, execute } = deployments;
9+
10+
const { deployer } = await getNamedAccounts();
11+
const config = deployConfig[network.name];
12+
13+
const positionManager = await deployments.get("NonfungiblePositionManager");
14+
const vaultFactory = await deployments.get("NFTXVaultFactoryUpgradeableV3");
15+
const inventoryStaking = await deployments.get(
16+
"NFTXInventoryStakingV3Upgradeable"
17+
);
18+
19+
const migratorZap = await deploy("MigratorZap", {
20+
from: deployer,
21+
args: [
22+
config.WETH,
23+
config.v2VaultFactory,
24+
config.v2Inventory,
25+
config.sushiRouter,
26+
positionManager.address,
27+
vaultFactory.address,
28+
inventoryStaking.address,
29+
],
30+
log: true,
31+
});
32+
33+
console.log("Setting fee exclusion for MigratorZap in V3...");
34+
// TODO: query if the deployer is the owner of the vault factory, and only then execute. Else just log because the owner will be the dao multisig in that case.
35+
// await execute(
36+
// "NFTXVaultFactoryUpgradeableV3",
37+
// { from: deployer },
38+
// "setFeeExclusion",
39+
// migratorZap.address,
40+
// true
41+
// );
42+
console.log("Fee exclusion set for MigratorZap in V3");
43+
console.warn(
44+
"[NOTE!] Set fee exclusion for MigratorZap in V2 of the protocol"
45+
);
46+
};
47+
export default func;
48+
func.tags = ["MigratorZap"];
49+
// func.dependencies = ["NFTXV3"];

deployments/mainnet/MigratorZap.json

Lines changed: 142 additions & 45 deletions
Large diffs are not rendered by default.

deployments/mainnet/solcInputs/b1e7c5dfcc692b1593f3699aa2e805b8.json

Lines changed: 431 additions & 0 deletions
Large diffs are not rendered by default.

deployments/sepolia/MigratorZap.json

Lines changed: 142 additions & 45 deletions
Large diffs are not rendered by default.

deployments/sepolia/solcInputs/b1e7c5dfcc692b1593f3699aa2e805b8.json

Lines changed: 431 additions & 0 deletions
Large diffs are not rendered by default.

src/interfaces/external/IUniswapV2Router02.sol

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,11 @@ interface IUniswapV2Router02 {
88
address to,
99
uint deadline
1010
) external returns (uint[] memory amounts);
11+
12+
function swapETHForExactTokens(
13+
uint256 amountOut,
14+
address[] calldata path,
15+
address to,
16+
uint256 deadline
17+
) external payable returns (uint256[] memory amounts);
1118
}

src/zaps/MigratorZap.sol

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ pragma solidity =0.8.15;
33

44
import {TransferLib} from "@src/lib/TransferLib.sol";
55

6-
import {IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
6+
import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
77
import {IWETH9} from "@uni-periphery/interfaces/external/IWETH9.sol";
8+
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
89
import {IERC1155} from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol";
910
import {INFTXVaultV2} from "@src/v2/interfaces/INFTXVaultV2.sol";
1011
import {INFTXVaultV3} from "@src/interfaces/INFTXVaultV3.sol";
@@ -22,16 +23,16 @@ import {INonfungiblePositionManager} from "@uni-periphery/interfaces/INonfungibl
2223
* @notice Migrates positions from NFTX v2 to v3
2324
* @dev This Zap must be excluded from vault fees in both NFTX v2 & v3.
2425
*/
25-
contract MigratorZap {
26+
contract MigratorZap is Ownable {
27+
using SafeERC20 for IERC20;
28+
2629
struct SushiToNFTXAMMParams {
2730
// Sushiswap pool address for vTokenV2 <> WETH pair
2831
address sushiPair;
2932
// LP balance to withdraw from sushiswap
3033
uint256 lpAmount;
3134
// Vault address in NFTX v2
3235
address vTokenV2;
33-
// NFT tokenIds to redeem from v2 vault
34-
uint256[] idsToRedeem;
3536
// If underlying vault NFT is ERC1155
3637
bool is1155;
3738
// Encoded permit signature for sushiPair
@@ -56,6 +57,7 @@ contract MigratorZap {
5657
uint256 private constant DEADLINE =
5758
0xf000000000000000000000000000000000000000000000000000000000000000;
5859
uint256 private constant DUST_THRESHOLD = 0.005 ether;
60+
uint256 private constant V2_VTOKEN_DUST = 100; // 100 wei
5961

6062
IWETH9 public immutable WETH;
6163
INFTXVaultFactoryV2 public immutable v2NFTXFactory;
@@ -126,7 +128,6 @@ contract MigratorZap {
126128
(vTokenV3, vTokenV3Balance, wethReceived) = _v2ToV3Vault(
127129
params.vTokenV2,
128130
vTokenV2Balance,
129-
params.idsToRedeem,
130131
params.vaultIdV3,
131132
params.is1155,
132133
0 // passing zero here as `positionManager.mint` takes this into account via `amount0Min` or `amount1Min`
@@ -194,7 +195,6 @@ contract MigratorZap {
194195
function v2InventoryToXNFT(
195196
uint256 vaultIdV2,
196197
uint256 shares,
197-
uint256[] calldata idsToRedeem,
198198
bool is1155,
199199
uint256 vaultIdV3,
200200
uint256 minWethToReceive
@@ -206,14 +206,38 @@ contract MigratorZap {
206206
address vTokenV2 = v2NFTXFactory.vault(vaultIdV2);
207207
uint256 vTokenV2Balance = IERC20(vTokenV2).balanceOf(address(this));
208208

209+
// to account for rounding down when withdrawing xTokens to vTokens
210+
uint256 numNftsRedeemable = vTokenV2Balance / 1 ether;
211+
uint256 numNftsRedeemableAfterDust = (vTokenV2Balance +
212+
V2_VTOKEN_DUST) / 1 ether;
213+
214+
if (numNftsRedeemableAfterDust > numNftsRedeemable) {
215+
// having few wei more of vTokens (100 wei at max) would result in redeeming one whole more NFT
216+
uint256 vTokensToBuy = numNftsRedeemableAfterDust *
217+
1 ether -
218+
vTokenV2Balance;
219+
// swapping ETH from this contract to get `vTokensToBuy`
220+
address[] memory path = new address[](2);
221+
path[0] = address(WETH);
222+
path[1] = vTokenV2;
223+
sushiRouter.swapETHForExactTokens{value: 1_000_000_000}(
224+
vTokensToBuy,
225+
path,
226+
address(this),
227+
block.timestamp
228+
);
229+
230+
// update var to the latest balance
231+
vTokenV2Balance = IERC20(vTokenV2).balanceOf(address(this));
232+
}
233+
209234
(
210235
address vTokenV3,
211236
uint256 vTokenV3Balance,
212237
uint256 wethReceived
213238
) = _v2ToV3Vault(
214239
vTokenV2,
215240
vTokenV2Balance,
216-
idsToRedeem,
217241
vaultIdV3,
218242
is1155,
219243
minWethToReceive
@@ -244,7 +268,6 @@ contract MigratorZap {
244268
function v2VaultToXNFT(
245269
address vTokenV2,
246270
uint256 vTokenV2Balance,
247-
uint256[] calldata idsToRedeem,
248271
bool is1155,
249272
uint256 vaultIdV3,
250273
uint256 minWethToReceive
@@ -262,7 +285,6 @@ contract MigratorZap {
262285
) = _v2ToV3Vault(
263286
vTokenV2,
264287
vTokenV2Balance,
265-
idsToRedeem,
266288
vaultIdV3,
267289
is1155,
268290
minWethToReceive
@@ -286,6 +308,20 @@ contract MigratorZap {
286308
);
287309
}
288310

311+
// =============================================================
312+
// ONLY OWNER WRITE
313+
// =============================================================
314+
315+
function rescueTokens(address token) external onlyOwner {
316+
if (token != address(0)) {
317+
uint256 balance = IERC20(token).balanceOf(address(this));
318+
IERC20(token).safeTransfer(msg.sender, balance);
319+
} else {
320+
uint256 balance = address(this).balance;
321+
TransferLib.transferETH(msg.sender, balance);
322+
}
323+
}
324+
289325
// =============================================================
290326
// INTERNAL HELPERS
291327
// =============================================================
@@ -337,7 +373,6 @@ contract MigratorZap {
337373
function _v2ToV3Vault(
338374
address vTokenV2,
339375
uint256 vTokenV2Balance,
340-
uint256[] calldata idsToRedeem,
341376
uint256 vaultIdV3,
342377
bool is1155,
343378
uint256 minWethToReceive
@@ -351,7 +386,8 @@ contract MigratorZap {
351386
{
352387
vTokenV3 = v3NFTXFactory.vault(vaultIdV3);
353388

354-
// redeem v2 vTokens. Directly transferring to the v3 vault
389+
// random redeem v2 vTokens. Directly transferring to the v3 vault
390+
uint256[] memory idsToRedeem;
355391
uint256[] memory idsRedeemed = INFTXVaultV2(vTokenV2).redeemTo(
356392
vTokenV2Balance / 1 ether,
357393
idsToRedeem,
@@ -410,4 +446,7 @@ contract MigratorZap {
410446
address(this)
411447
);
412448
}
449+
450+
// To fund the zap with ETH to swap for missing vTokens during v2 redeem
451+
receive() external payable {}
413452
}

test/zaps/MigratorZap.t.sol

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,6 @@ contract MigratorZapTests is TestBase {
107107
DEADLINE,
108108
alicePrivateKey
109109
);
110-
// empty array = random redeem
111-
uint256[] memory idsToRedeem;
112110
address vTokenV3 = vaultFactory.vault(vaultIdV3);
113111
(int24 tickLower, int24 tickUpper, uint160 currentSqrtP) = _getTicks(
114112
vTokenV3,
@@ -123,7 +121,6 @@ contract MigratorZapTests is TestBase {
123121
sushiPair: MILADY_WETH_SLP,
124122
lpAmount: liquidityToMigrate,
125123
vTokenV2: V2_MILADY_VTOKEN,
126-
idsToRedeem: idsToRedeem,
127124
is1155: false,
128125
permitSig: permitSig,
129126
vaultIdV3: vaultIdV3,
@@ -146,8 +143,6 @@ contract MigratorZapTests is TestBase {
146143
function test_v2InventoryToXNFT_Success() external {
147144
uint256 vaultIdV2 = INFTXVaultV2(V2_MILADY_VTOKEN).vaultId();
148145
uint256 shares = IERC20(xMILADY).balanceOf(xMILADY_Holder);
149-
// empty array = random redeem
150-
uint256[] memory idsToRedeem;
151146

152147
vm.startPrank(xMILADY_Holder);
153148
vm.warp(v2Inventory.timelockUntil(vaultIdV2, xMILADY_Holder) + 1);
@@ -156,7 +151,6 @@ contract MigratorZapTests is TestBase {
156151
uint256 xNFTId = migratorZap.v2InventoryToXNFT(
157152
vaultIdV2,
158153
shares,
159-
idsToRedeem,
160154
false, // is1155
161155
vaultIdV3,
162156
0
@@ -168,18 +162,70 @@ contract MigratorZapTests is TestBase {
168162
assertEq(inventoryStaking.ownerOf(xNFTId), xMILADY_Holder);
169163
}
170164

165+
// test that the Zap buys up extra wei of vTokens to redeem a whole more NFT
166+
function test_v2InventoryToXNFT_RoundUp_Success() external {
167+
address user = makeAddr("user");
168+
169+
// Transfer 2 MILADY to the user
170+
hoax(MILADY_Holder);
171+
IERC20(V2_MILADY_VTOKEN).transfer(user, 2 ether);
172+
173+
// Send some ETH to the Zap, to allow buying extra vTokens
174+
(bool success, ) = address(migratorZap).call{value: 1 ether}("");
175+
require(success, "Error: sending ETH");
176+
177+
startHoax(user);
178+
179+
// Create an xMILADY position with 1.999... vTokens so on redemption we get back 1.999... vTokens and the Zap rounds it up
180+
uint256 vaultIdV2 = INFTXVaultV2(V2_MILADY_VTOKEN).vaultId();
181+
uint256 vTokensToStake = 2 ether - 1; // 1 wei less than 2 vTokens
182+
183+
IERC20(V2_MILADY_VTOKEN).approve(address(v2Inventory), vTokensToStake);
184+
v2Inventory.deposit(vaultIdV2, vTokensToStake);
185+
186+
// jump into the future so the migratorZap can pull xTokens from the user's address
187+
uint256 timelockedUntil = v2Inventory.timelockUntil(vaultIdV2, user);
188+
vm.warp(timelockedUntil + 1);
189+
190+
uint256 xMILADYBalance = IERC20(xMILADY).balanceOf(user);
191+
assertGt(xMILADYBalance, 0);
192+
193+
// Approve the Zap to take the xMILADY
194+
IERC20(xMILADY).approve(address(migratorZap), xMILADYBalance);
195+
196+
// Migrate V2 xMILADY to V3 XNFT
197+
uint256 xNFTId = migratorZap.v2InventoryToXNFT(
198+
vaultIdV2,
199+
xMILADYBalance,
200+
false, // is1155
201+
vaultIdV3,
202+
0
203+
);
204+
205+
uint256 finalBalance = IERC20(xMILADY).balanceOf(user);
206+
assertEq(finalBalance, 0);
207+
208+
assertEq(inventoryStaking.ownerOf(xNFTId), user);
209+
210+
(, , , , , uint256 vTokenShareBalance, , ) = inventoryStaking.positions(
211+
xNFTId
212+
);
213+
214+
// Without rounding up, the 0.999... from the 1.999... vTokens unstaked would have been sold off to WETH, and the inventory position would had just 1 vToken staked
215+
// so checking here that the rounding up worked and we actually staked 2 vTokens instead.
216+
// deducting `MINIMUM_LIQUIDITY` (= 1_000) from the vTokenShareBalance because the totalSupply is 0.
217+
assertEq(vTokenShareBalance, 2 ether - 1_000);
218+
}
219+
171220
function test_v2VaultToXNFT() external {
172221
uint256 amount = IERC20(V2_MILADY_VTOKEN).balanceOf(MILADY_Holder);
173-
// empty array = random redeem
174-
uint256[] memory idsToRedeem;
175222

176223
startHoax(MILADY_Holder);
177224

178225
IERC20(V2_MILADY_VTOKEN).approve(address(migratorZap), amount);
179226
uint256 xNFTId = migratorZap.v2VaultToXNFT(
180227
V2_MILADY_VTOKEN,
181228
amount,
182-
idsToRedeem,
183229
false, // is1155
184230
vaultIdV3,
185231
0

0 commit comments

Comments
 (0)