diff --git a/packages/horizon/contracts/staking/HorizonStakingExtension.sol b/packages/horizon/contracts/staking/HorizonStakingExtension.sol index 41dda175f..8f7ab8449 100644 --- a/packages/horizon/contracts/staking/HorizonStakingExtension.sol +++ b/packages/horizon/contracts/staking/HorizonStakingExtension.sol @@ -337,6 +337,8 @@ contract HorizonStakingExtension is HorizonStakingBase, IL2StakingBase, IHorizon * The specified amount is added to the delegator's delegation; the delegator's * address and the indexer's address are specified in the _delegationData struct. * Note that no delegation tax is applied here. + * @dev Note that L1 staking contract only allows delegation transfer if the indexer has already transferred, + * this means the corresponding delegation pool exists. * @param _tokens Amount of tokens that were transferred * @param _delegationData struct containing the delegator's address and the indexer's address */ @@ -356,7 +358,7 @@ contract HorizonStakingExtension is HorizonStakingBase, IL2StakingBase, IHorizon } // Calculate shares to issue (without applying any delegation tax) - uint256 shares = (pool.tokens == 0) ? _tokens : ((_tokens * pool.shares) / pool.tokens); + uint256 shares = (pool.tokens == 0) ? _tokens : ((_tokens * pool.shares) / (pool.tokens - pool.tokensThawing)); if (shares == 0 || _tokens < MINIMUM_DELEGATION) { // If no shares would be issued (probably a rounding issue or attack), diff --git a/packages/horizon/test/staking/delegation/undelegate.t.sol b/packages/horizon/test/staking/delegation/undelegate.t.sol index 4ffde07ff..8c578b768 100644 --- a/packages/horizon/test/staking/delegation/undelegate.t.sol +++ b/packages/horizon/test/staking/delegation/undelegate.t.sol @@ -25,10 +25,12 @@ contract HorizonStakingUndelegateTest is HorizonStakingTest { uint256 amount, uint256 delegationAmount, uint256 undelegateSteps - ) public useIndexer useProvision(amount, 0, 0) useDelegation(delegationAmount) { + ) public useIndexer useProvision(amount, 0, 0) { undelegateSteps = bound(undelegateSteps, 1, 10); + delegationAmount = bound(delegationAmount, MIN_DELEGATION + 10 wei, MAX_STAKING_TOKENS); resetPrank(users.delegator); + _delegate(users.indexer, subgraphDataServiceAddress, delegationAmount, 0); Delegation memory delegation = _getDelegation(subgraphDataServiceAddress); // there is a min delegation amount of 1 ether after undelegating diff --git a/packages/horizon/test/staking/transfer-tools/ttools.t.sol b/packages/horizon/test/staking/transfer-tools/ttools.t.sol index 9175f74e5..fcc3c7d69 100644 --- a/packages/horizon/test/staking/transfer-tools/ttools.t.sol +++ b/packages/horizon/test/staking/transfer-tools/ttools.t.sol @@ -5,72 +5,180 @@ import "forge-std/Test.sol"; import { HorizonStakingTest } from "../HorizonStaking.t.sol"; import { IL2StakingTypes } from "@graphprotocol/contracts/contracts/l2/staking/IL2StakingTypes.sol"; +import { IL2StakingBase } from "@graphprotocol/contracts/contracts/l2/staking/IL2StakingBase.sol"; +import { IHorizonStakingExtension } from "../../../contracts/interfaces/internal/IHorizonStakingExtension.sol"; contract HorizonStakingTransferToolsTest is HorizonStakingTest { + event Transfer(address indexed from, address indexed to, uint tokens); + /* * TESTS */ - function testOnTransfer_RevertWhen_InvalidCaller( - uint256 amount, - uint256 delegationAmount - ) public useIndexer useProvision(amount, 0, 0) useDelegation(delegationAmount) { + function testOnTransfer_RevertWhen_InvalidCaller() public { + bytes memory data = abi.encode(uint8(0), new bytes(0)); // Valid codes are 0 and 1 + vm.expectRevert(bytes("ONLY_GATEWAY")); + staking.onTokenTransfer(counterpartStaking, 0, data); + } + + function testOnTransfer_RevertWhen_InvalidCounterpart() public { + resetPrank(graphTokenGatewayAddress); - bytes memory data = abi.encode(uint8(0), new bytes(0)); // Valid codes are 0 and 1 - vm.expectRevert(bytes("ONLY_GATEWAY")); - staking.onTokenTransfer(counterpartStaking, 0, data); + bytes memory data = abi.encode(uint8(0), new bytes(0)); // Valid codes are 0 and 1 + vm.expectRevert(bytes("ONLY_L1_STAKING_THROUGH_BRIDGE")); + staking.onTokenTransfer(address(staking), 0, data); } - function testOnTransfer_RevertWhen_InvalidCounterpart( - uint256 amount, - uint256 delegationAmount - ) public useIndexer useProvision(amount, 0, 0) useDelegation(delegationAmount) { - resetPrank(graphTokenGatewayAddress); + function testOnTransfer_RevertWhen_InvalidData() public { + resetPrank(graphTokenGatewayAddress); - bytes memory data = abi.encode(uint8(0), new bytes(0)); // Valid codes are 0 and 1 - vm.expectRevert(bytes("ONLY_L1_STAKING_THROUGH_BRIDGE")); - staking.onTokenTransfer(address(staking), 0, data); + vm.expectRevert(); + staking.onTokenTransfer(counterpartStaking, 0, new bytes(0)); } - function testOnTransfer_RevertWhen_InvalidData( - uint256 amount, - uint256 delegationAmount - ) public useIndexer useProvision(amount, 0, 0) useDelegation(delegationAmount) { - resetPrank(graphTokenGatewayAddress); + function testOnTransfer_RevertWhen_InvalidCode() public { + resetPrank(graphTokenGatewayAddress); - vm.expectRevert(); - staking.onTokenTransfer(counterpartStaking, 0, new bytes(0)); + bytes memory data = abi.encode(uint8(2), new bytes(0)); // Valid codes are 0 and 1 + vm.expectRevert(bytes("INVALID_CODE")); + staking.onTokenTransfer(counterpartStaking, 0, data); } - function testOnTransfer_RevertWhen_InvalidCode( - uint256 amount, - uint256 delegationAmount - ) public useIndexer useProvision(amount, 0, 0) useDelegation(delegationAmount) { - resetPrank(graphTokenGatewayAddress); + function testOnTransfer_ReceiveDelegation_RevertWhen_InvalidData() public { + resetPrank(graphTokenGatewayAddress); - bytes memory data = abi.encode(uint8(2), new bytes(0)); // Valid codes are 0 and 1 - vm.expectRevert(bytes("INVALID_CODE")); - staking.onTokenTransfer(counterpartStaking, 0, data); + bytes memory data = abi.encode(uint8(IL2StakingTypes.L1MessageCodes.RECEIVE_DELEGATION_CODE), new bytes(0)); + vm.expectRevert(); + staking.onTokenTransfer(counterpartStaking, 0, data); } - function testOnTransfer_ReceiveDelegation_RevertWhen_InvalidData( - uint256 amount, - uint256 delegationAmount - ) public useIndexer useProvision(amount, 0, 0) useDelegation(delegationAmount) { - resetPrank(graphTokenGatewayAddress); + function testOnTransfer_ReceiveDelegation(uint256 amount) public { + amount = bound(amount, 1 ether, MAX_STAKING_TOKENS); + + // create provision and legacy delegation pool - this is done by the bridge when indexers move to L2 + resetPrank(users.indexer); + _createProvision(subgraphDataServiceLegacyAddress, 100 ether, 0, 0); + + resetPrank(users.delegator); + _delegateLegacy(users.indexer, 1 ether); - bytes memory data = abi.encode(uint8(IL2StakingTypes.L1MessageCodes.RECEIVE_DELEGATION_CODE), new bytes(0)); - vm.expectRevert(); - staking.onTokenTransfer(counterpartStaking, 0, data); + // send amount to staking contract - this should be done by the bridge + resetPrank(users.delegator); + token.transfer(address(staking), amount); + + resetPrank(graphTokenGatewayAddress); + bytes memory data = abi.encode( + uint8(IL2StakingTypes.L1MessageCodes.RECEIVE_DELEGATION_CODE), + abi.encode(users.indexer, users.delegator) + ); + _onTokenTransfer_ReceiveDelegation(counterpartStaking, amount, data); } - function testOnTransfer_ReceiveDelegation( - uint256 amount, - uint256 delegationAmount - ) public useIndexer useProvision(amount, 0, 0) useDelegation(delegationAmount) { - resetPrank(graphTokenGatewayAddress); + function testOnTransfer_ReceiveDelegation_WhenThawing(uint256 amount) public { + amount = bound(amount, 1 ether, MAX_STAKING_TOKENS); + uint256 originalDelegationAmount = 10 ether; + + // create provision and legacy delegation pool - this is done by the bridge when indexers move to L2 + resetPrank(users.indexer); + _createProvision(subgraphDataServiceLegacyAddress, 100 ether, 0, 1 days); + + resetPrank(users.delegator); + _delegateLegacy(users.indexer, originalDelegationAmount); + + // send amount to staking contract - this should be done by the bridge + resetPrank(users.delegator); + token.transfer(address(staking), amount); + + // thaw some delegation before receiving new delegation from L1 + resetPrank(users.delegator); + _undelegateLegacy(users.indexer, originalDelegationAmount / 10); + + resetPrank(graphTokenGatewayAddress); + bytes memory data = abi.encode( + uint8(IL2StakingTypes.L1MessageCodes.RECEIVE_DELEGATION_CODE), + abi.encode(users.indexer, users.delegator) + ); + _onTokenTransfer_ReceiveDelegation(counterpartStaking, amount, data); + } + + /** + * HELPERS + */ + + function _onTokenTransfer_ReceiveDelegation(address from, uint256 tokens, bytes memory data) internal { + (, bytes memory fnData) = abi.decode(data, (uint8, bytes)); + (address serviceProvider, address delegator) = abi.decode(fnData, (address, address)); + bytes32 slotPoolTokens = bytes32(uint256(keccak256(abi.encode(serviceProvider, 20))) + 2); + + // before + DelegationPool memory beforePool = staking.getDelegationPool(serviceProvider, subgraphDataServiceLegacyAddress); + Delegation memory beforeDelegation = staking.getDelegation( + serviceProvider, + subgraphDataServiceLegacyAddress, + delegator + ); + uint256 beforeStoragePoolTokens = uint256(vm.load(address(staking), slotPoolTokens)); + uint256 beforeDelegatedTokens = staking.getDelegatedTokensAvailable( + serviceProvider, + subgraphDataServiceLegacyAddress + ); + uint256 beforeDelegatorBalance = token.balanceOf(delegator); + uint256 beforeStakingBalance = token.balanceOf(address(staking)); + uint256 calcShares = (beforePool.tokens == 0) + ? tokens + : ((tokens * beforePool.shares) / (beforePool.tokens - beforePool.tokensThawing)); + + // onTokenTransfer + if (calcShares == 0 || tokens < 1 ether) { + vm.expectEmit(); + emit Transfer(address(staking), delegator, tokens); + vm.expectEmit(); + emit IL2StakingBase.TransferredDelegationReturnedToDelegator(serviceProvider, delegator, tokens); + } else { + vm.expectEmit(); + emit IHorizonStakingExtension.StakeDelegated(serviceProvider, delegator, tokens, calcShares); + } + staking.onTokenTransfer(counterpartStaking, tokens, data); + + // after + DelegationPool memory afterPool = staking.getDelegationPool(serviceProvider, subgraphDataServiceLegacyAddress); + Delegation memory afterDelegation = staking.getDelegation( + serviceProvider, + subgraphDataServiceLegacyAddress, + delegator + ); + uint256 afterStoragePoolTokens = uint256(vm.load(address(staking), slotPoolTokens)); + uint256 afterDelegatedTokens = staking.getDelegatedTokensAvailable( + serviceProvider, + subgraphDataServiceLegacyAddress + ); + uint256 afterDelegatorBalance = token.balanceOf(delegator); + uint256 afterStakingBalance = token.balanceOf(address(staking)); + + uint256 deltaShares = afterDelegation.shares - beforeDelegation.shares; - bytes memory data = abi.encode(uint8(IL2StakingTypes.L1MessageCodes.RECEIVE_DELEGATION_CODE), abi.encode(users.indexer, users.delegator)); - staking.onTokenTransfer(counterpartStaking, 0, data); + // assertions + if (calcShares == 0 || tokens < 1 ether) { + assertEq(beforePool.tokens, afterPool.tokens); + assertEq(beforePool.shares, afterPool.shares); + assertEq(beforePool.tokensThawing, afterPool.tokensThawing); + assertEq(beforePool.sharesThawing, afterPool.sharesThawing); + assertEq(0, deltaShares); + assertEq(beforeDelegatedTokens, afterDelegatedTokens); + assertEq(beforeStoragePoolTokens, afterStoragePoolTokens); + assertEq(beforeDelegatorBalance + tokens, afterDelegatorBalance); + assertEq(beforeStakingBalance - tokens, afterStakingBalance); + } else { + assertEq(beforePool.tokens + tokens, afterPool.tokens); + assertEq(beforePool.shares + calcShares, afterPool.shares); + assertEq(beforePool.tokensThawing, afterPool.tokensThawing); + assertEq(beforePool.sharesThawing, afterPool.sharesThawing); + assertEq(calcShares, deltaShares); + assertEq(beforeDelegatedTokens + tokens, afterDelegatedTokens); + // Ensure correct slot is being updated, pools are stored in different storage locations for legacy subgraph data service + assertEq(beforeStoragePoolTokens + tokens, afterStoragePoolTokens); + assertEq(beforeDelegatorBalance, afterDelegatorBalance); + assertEq(beforeStakingBalance, afterStakingBalance); + } } }