From 549e376ad230767fa1b547575c045822e9c65d13 Mon Sep 17 00:00:00 2001 From: Daniel Beal Date: Wed, 27 Aug 2025 13:20:38 +0900 Subject: [PATCH] feat(treasury-market): changes to import positions as part of migration from optimism it will be helpful to be able to import positions directly with no fancyx linear ticket forthcoming --- .../contracts/TreasuryMarket.sol | 21 +++ .../contracts/interfaces/ITreasuryMarket.sol | 5 + markets/treasury-market/package.json | 2 +- .../test/TreasuryMarket.test.sol | 152 ++++++++++++++++++ .../contracts/interfaces/IVaultModule.sol | 16 ++ .../contracts/modules/core/VaultModule.sol | 23 +++ protocol/synthetix/package.json | 2 +- 7 files changed, 219 insertions(+), 2 deletions(-) diff --git a/markets/treasury-market/contracts/TreasuryMarket.sol b/markets/treasury-market/contracts/TreasuryMarket.sol index 5a407c8cc4..317f8d9baa 100644 --- a/markets/treasury-market/contracts/TreasuryMarket.sol +++ b/markets/treasury-market/contracts/TreasuryMarket.sol @@ -292,11 +292,23 @@ contract TreasuryMarket is ITreasuryMarket, Ownable, UUPSImplementation, IMarket // return the account token to the user. we use the "unsafe" call here because the account is being returned to the same address // it came from, so its probably ok and the account will be able to handle receipt of the NFT. + // forge-lint: disable-next-line(erc20-unchecked-transfer) accountToken.transferFrom(address(this), sender, accountId); emit AccountUnsaddled(accountId, accountCollateral, neededToRepay); } + function importStaker( + uint128 accountId, + uint256 saddledCollateralAmount, + LoanInfo memory loan, + AuxTokenInfo memory newAuxInfo + ) external onlyOwner { + saddledCollateral[accountId] = saddledCollateralAmount; + loans[accountId] = loan; + auxTokenInfo[accountId] = newAuxInfo; + } + function reportAuxToken(uint128 accountId) external { if (saddledCollateral[accountId] == 0 || loans[accountId].loanAmount == 0) { return; @@ -526,10 +538,19 @@ contract TreasuryMarket is ITreasuryMarket, Ownable, UUPSImplementation, IMarket _upgradeTo(to); } + function getLoanInfo(uint128 accountId) external view returns (LoanInfo memory) { + return loans[accountId]; + } + + function getAuxTokenInfo(uint128 accountId) external view returns (AuxTokenInfo memory) { + return auxTokenInfo[accountId]; + } + /** * @inheritdoc IERC721Receiver * @dev This function is required so that self transfer in `unsaddle` works as expected. */ + /// forge-lint: disable-next-line(mixed-case-function) function onERC721Received( address, /*operator*/ diff --git a/markets/treasury-market/contracts/interfaces/ITreasuryMarket.sol b/markets/treasury-market/contracts/interfaces/ITreasuryMarket.sol index 6c22f657f3..779b33edb4 100644 --- a/markets/treasury-market/contracts/interfaces/ITreasuryMarket.sol +++ b/markets/treasury-market/contracts/interfaces/ITreasuryMarket.sol @@ -133,6 +133,11 @@ interface ITreasuryMarket { */ error InsufficientExcessDebt(int256 neededToRepay, int256 ableToRepay); + /** + * @notice Emitted when `transferFrom` or `transfer` of an account token fails + */ + error AccountTokenTransferFailed(uint128 accountId); + /** * @notice called by the owner to register this market with v3. This is an initialization call only. */ diff --git a/markets/treasury-market/package.json b/markets/treasury-market/package.json index aa57fd3cfb..c99a7292e4 100644 --- a/markets/treasury-market/package.json +++ b/markets/treasury-market/package.json @@ -1,6 +1,6 @@ { "name": "@synthetixio/treasury-market", - "version": "3.13.0", + "version": "3.14.0", "description": "V3 Market to allow a trusted entity to manage excess liquidity on behalf of stakers", "publishConfig": { "access": "public" diff --git a/markets/treasury-market/test/TreasuryMarket.test.sol b/markets/treasury-market/test/TreasuryMarket.test.sol index 6ef2115ab3..db346f9cbd 100644 --- a/markets/treasury-market/test/TreasuryMarket.test.sol +++ b/markets/treasury-market/test/TreasuryMarket.test.sol @@ -16,6 +16,8 @@ interface IV3TestCoreProxy is IV3CoreProxy {} /* solhint-disable numcast/safe-cast */ +/// forge-lint: disable-start(all) + contract TreasuryMarketTest is Test, IERC721Receiver { TreasuryMarket private market; IV3TestCoreProxy private v3System; @@ -1129,6 +1131,156 @@ contract TreasuryMarketTest is Test, IERC721Receiver { assertEq(market.loanedAmount(accountId), initialDebt / 4); } + function test_RevertIf_ImportStakerUnauthorized() external { + // Create test data + uint128 testAccountId = 100; + uint256 testSaddledCollateralAmount = 1000 ether; + ITreasuryMarket.LoanInfo memory testLoan = ITreasuryMarket.LoanInfo({ + startTime: uint64(block.timestamp), + power: 2, + duration: 365 days, + loanAmount: 100 ether + }); + ITreasuryMarket.AuxTokenInfo memory testAuxTokenInfo = ITreasuryMarket.AuxTokenInfo({ + amount: 50 ether, + lastUpdated: uint64(block.timestamp), + timeInsufficient: 0, + epoch: 1 + }); + + // Attempt to call importStaker as non-owner and expect revert + vm.expectRevert(abi.encodeWithSelector(AccessError.Unauthorized.selector, address(this))); + market.importStaker(testAccountId, testSaddledCollateralAmount, testLoan, testAuxTokenInfo); + } + + function test_ImportStakerOverwritesExistingData() external { + // First, set some initial data + vm.warp(100000000); + uint128 testAccountId = 300; + uint256 initialSaddledAmount = 1000 ether; + ITreasuryMarket.LoanInfo memory initialLoan = ITreasuryMarket.LoanInfo({ + startTime: uint64(block.timestamp - 1000), + power: 1, + duration: 365 days, + loanAmount: 50 ether + }); + ITreasuryMarket.AuxTokenInfo memory initialAuxInfo = ITreasuryMarket.AuxTokenInfo({ + amount: 25 ether, + lastUpdated: uint64(block.timestamp - 500), + timeInsufficient: 0, + epoch: 0 + }); + + vm.prank(market.owner()); + market.importStaker(testAccountId, initialSaddledAmount, initialLoan, initialAuxInfo); + + // Now overwrite with new data + uint256 newSaddledAmount = 3000 ether; + ITreasuryMarket.LoanInfo memory newLoan = ITreasuryMarket.LoanInfo({ + startTime: uint64(block.timestamp), + power: 4, + duration: 730 days, + loanAmount: 200 ether + }); + ITreasuryMarket.AuxTokenInfo memory newAuxInfo = ITreasuryMarket.AuxTokenInfo({ + amount: 100 ether, + lastUpdated: uint64(block.timestamp), + timeInsufficient: 172800, // 2 days + epoch: 3 + }); + + vm.prank(market.owner()); + market.importStaker(testAccountId, newSaddledAmount, newLoan, newAuxInfo); + + // Verify the data was overwritten correctly + assertEq( + market.saddledCollateral(testAccountId), + newSaddledAmount, + "saddledCollateral not overwritten correctly" + ); + + assertEq( + market.getLoanInfo(testAccountId).startTime, + newLoan.startTime, + "loan startTime not overwritten correctly" + ); + assertEq( + market.getLoanInfo(testAccountId).power, + newLoan.power, + "loan power not overwritten correctly" + ); + assertEq( + market.getLoanInfo(testAccountId).duration, + newLoan.duration, + "loan duration not overwritten correctly" + ); + assertEq( + market.getLoanInfo(testAccountId).loanAmount, + newLoan.loanAmount, + "loan loanAmount not overwritten correctly" + ); + + assertEq( + market.getAuxTokenInfo(testAccountId).amount, + newAuxInfo.amount, + "auxTokenInfo amount not overwritten correctly" + ); + assertEq( + market.getAuxTokenInfo(testAccountId).lastUpdated, + newAuxInfo.lastUpdated, + "auxTokenInfo lastUpdated not overwritten correctly" + ); + assertEq( + market.getAuxTokenInfo(testAccountId).timeInsufficient, + newAuxInfo.timeInsufficient, + "auxTokenInfo timeInsufficient not overwritten correctly" + ); + assertEq( + market.getAuxTokenInfo(testAccountId).epoch, + newAuxInfo.epoch, + "auxTokenInfo epoch not overwritten correctly" + ); + } + + function test_ImportStakerWithZeroValues() external { + // Test with zero/minimal values to ensure no edge case issues + uint128 testAccountId = 400; + uint256 testSaddledCollateralAmount = 0; + ITreasuryMarket.LoanInfo memory testLoan = ITreasuryMarket.LoanInfo({ + startTime: 0, + power: 0, + duration: 0, + loanAmount: 0 + }); + ITreasuryMarket.AuxTokenInfo memory testAuxTokenInfo = ITreasuryMarket.AuxTokenInfo({ + amount: 0, + lastUpdated: 0, + timeInsufficient: 0, + epoch: 0 + }); + + vm.prank(market.owner()); + market.importStaker(testAccountId, testSaddledCollateralAmount, testLoan, testAuxTokenInfo); + + // Verify all values are set to zero + assertEq(market.saddledCollateral(testAccountId), 0, "saddledCollateral should be 0"); + + (uint64 startTime, uint32 power, uint32 duration, uint128 loanAmount) = market.loans( + testAccountId + ); + assertEq(startTime, 0, "loan startTime should be 0"); + assertEq(power, 0, "loan power should be 0"); + assertEq(duration, 0, "loan duration should be 0"); + assertEq(loanAmount, 0, "loan loanAmount should be 0"); + + (uint128 amount, uint64 lastUpdated, uint32 timeInsufficient, uint32 epoch) = market + .auxTokenInfo(testAccountId); + assertEq(amount, 0, "auxTokenInfo amount should be 0"); + assertEq(lastUpdated, 0, "auxTokenInfo lastUpdated should be 0"); + assertEq(timeInsufficient, 0, "auxTokenInfo timeInsufficient should be 0"); + assertEq(epoch, 0, "auxTokenInfo epoch should be 0"); + } + function onERC721Received( address, /*operator*/ diff --git a/protocol/synthetix/contracts/interfaces/IVaultModule.sol b/protocol/synthetix/contracts/interfaces/IVaultModule.sol index d2523e4664..b4f1e76dae 100644 --- a/protocol/synthetix/contracts/interfaces/IVaultModule.sol +++ b/protocol/synthetix/contracts/interfaces/IVaultModule.sol @@ -89,6 +89,22 @@ interface IVaultModule { uint128 newPoolId ) external; + /** + * @notice Allows for the owner to override create a position (migrated from another network). + * @param accountId The id of the account associated with the position that will be updated. + * @param poolId The id of the pool associated with the position. + * @param collateralToken The address of the collateral used in the position. + * @param totalCollateral The total amount of collateral used in the position. + * @param totalDebt The total amount of debt used in the position. + */ + function importPosition( + uint128 accountId, + uint128 poolId, + address collateralToken, + uint256 totalCollateral, + int256 totalDebt + ) external; + /** * @notice Returns the collateralization ratio of the specified liquidity position. If debt is negative, this function will return 0. * @dev Call this function using `callStatic` to treat it as a view function. diff --git a/protocol/synthetix/contracts/modules/core/VaultModule.sol b/protocol/synthetix/contracts/modules/core/VaultModule.sol index 9d26b67d97..dccaac291a 100644 --- a/protocol/synthetix/contracts/modules/core/VaultModule.sol +++ b/protocol/synthetix/contracts/modules/core/VaultModule.sol @@ -4,6 +4,7 @@ pragma solidity >=0.8.11 <0.9.0; import "@synthetixio/core-contracts/contracts/utils/DecimalMath.sol"; import "@synthetixio/core-contracts/contracts/utils/SafeCast.sol"; import "@synthetixio/core-contracts/contracts/utils/ERC2771Context.sol"; +import "@synthetixio/core-contracts/contracts/ownership/OwnableStorage.sol"; import "../../storage/Account.sol"; import "../../storage/Pool.sol"; @@ -263,6 +264,28 @@ contract VaultModule is IVaultModule { ); } + /** + * @inheritdoc IVaultModule + */ + function importPosition( + uint128 accountId, + uint128 poolId, + address collateralToken, + uint256 totalCollateral, + int256 totalDebt + ) external { + OwnableStorage.onlyOwner(); + Pool.load(poolId).vaults[collateralToken].currentEpoch().updateAccountPosition( + accountId, + totalCollateral, + 1 ether + ); + Pool.load(poolId).vaults[collateralToken].currentEpoch().assignDebtToAccount( + accountId, + totalDebt + ); + } + /** * @inheritdoc IVaultModule */ diff --git a/protocol/synthetix/package.json b/protocol/synthetix/package.json index 37fcac635b..253043b258 100644 --- a/protocol/synthetix/package.json +++ b/protocol/synthetix/package.json @@ -1,6 +1,6 @@ { "name": "@synthetixio/main", - "version": "3.13.0", + "version": "3.14.0", "description": "Core Synthetix Protocol Contracts", "publishConfig": { "access": "public"