diff --git a/packages/contracts/contracts/governance/Managed.sol b/packages/contracts/contracts/governance/Managed.sol index fb65e71b9..f0172dc94 100644 --- a/packages/contracts/contracts/governance/Managed.sol +++ b/packages/contracts/contracts/governance/Managed.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity ^0.7.6; +pragma solidity >=0.6.12 <0.9.0; import { IController } from "./IController.sol"; diff --git a/packages/horizon/contracts/GraphDirectory.sol b/packages/horizon/contracts/GraphDirectory.sol index dfd9973ea..b509f792a 100644 --- a/packages/horizon/contracts/GraphDirectory.sol +++ b/packages/horizon/contracts/GraphDirectory.sol @@ -21,6 +21,8 @@ contract GraphDirectory { // Legacy contracts (pre-Horizon) used for StakingBackwardCompatibility address public immutable REWARDS_MANAGER; address public immutable CURATION; + address public immutable GRAPH_PAYMENTS; + address public immutable GRAPH_ESCROW; constructor(address _controller) { CONTROLLER = _controller; @@ -30,5 +32,7 @@ contract GraphDirectory { GRAPH_TOKEN_GATEWAY = IController(_controller).getContractProxy(keccak256("GraphTokenGateway")); REWARDS_MANAGER = IController(_controller).getContractProxy(keccak256("RewardsManager")); CURATION = IController(_controller).getContractProxy(keccak256("Curation")); + GRAPH_PAYMENTS = IController(_controller).getContractProxy(keccak256("GraphPayments")); + GRAPH_ESCROW = IController(_controller).getContractProxy(keccak256("GraphEscrow")); } } diff --git a/packages/horizon/contracts/escrow/GraphEscrow.sol b/packages/horizon/contracts/escrow/GraphEscrow.sol new file mode 100644 index 000000000..b1c21f54d --- /dev/null +++ b/packages/horizon/contracts/escrow/GraphEscrow.sol @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.24; + +import { IGraphToken } from "../interfaces/IGraphToken.sol"; +import { IGraphEscrow } from "../interfaces/IGraphEscrow.sol"; +import { IGraphPayments } from "../interfaces/IGraphPayments.sol"; +import { GraphDirectory } from "../GraphDirectory.sol"; +import { GraphEscrowStorageV1Storage } from "./GraphEscrowStorage.sol"; +import { TokenUtils } from "../libraries/TokenUtils.sol"; + +contract GraphEscrow is IGraphEscrow, GraphEscrowStorageV1Storage, GraphDirectory { + // -- Errors -- + + error GraphEscrowNotGraphPayments(); + error GraphEscrowInputsLengthMismatch(); + error GraphEscrowInsufficientThawAmount(); + error GraphEscrowInsufficientAmount(uint256 available, uint256 required); + error GraphEscrowNotThawing(); + error GraphEscrowStillThawing(uint256 currentTimestamp, uint256 thawEndTimestamp); + error GraphEscrowThawingPeriodTooLong(uint256 thawingPeriod, uint256 maxThawingPeriod); + error GraphEscrowCollectorNotAuthorized(address sender, address dataService); + error GraphEscrowCollectorInsufficientAmount(uint256 available, uint256 required); + + // -- Events -- + + event AuthorizedCollector(address indexed sender, address indexed dataService); + event ThawCollector(address indexed sender, address indexed dataService); + event CancelThawCollector(address indexed sender, address indexed dataService); + event RevokeCollector(address indexed sender, address indexed dataService); + event Deposit(address indexed sender, address indexed receiver, uint256 amount); + event CancelThaw(address indexed sender, address indexed receiver); + event Thaw( + address indexed sender, + address indexed receiver, + uint256 amount, + uint256 totalAmountThawing, + uint256 thawEndTimestamp + ); + event Withdraw(address indexed sender, address indexed receiver, uint256 amount); + event Collect(address indexed sender, address indexed receiver, uint256 amount); + + // -- Constructor -- + + constructor( + address _controller, + uint256 _revokeCollectorThawingPeriod, + uint256 _withdrawEscrowThawingPeriod + ) GraphDirectory(_controller) { + if (_revokeCollectorThawingPeriod > MAX_THAWING_PERIOD) { + revert GraphEscrowThawingPeriodTooLong(_revokeCollectorThawingPeriod, MAX_THAWING_PERIOD); + } + + if (_withdrawEscrowThawingPeriod > MAX_THAWING_PERIOD) { + revert GraphEscrowThawingPeriodTooLong(_withdrawEscrowThawingPeriod, MAX_THAWING_PERIOD); + } + + revokeCollectorThawingPeriod = _revokeCollectorThawingPeriod; + withdrawEscrowThawingPeriod = _withdrawEscrowThawingPeriod; + } + + // approve a data service to collect funds + function approveCollector(address dataService, uint256 amount) external { + authorizedCollectors[msg.sender][dataService].authorized = true; + authorizedCollectors[msg.sender][dataService].amount = amount; + emit AuthorizedCollector(msg.sender, dataService); + } + + // thaw a data service's collector authorization + function thawCollector(address dataService) external { + authorizedCollectors[msg.sender][dataService].thawEndTimestamp = block.timestamp + revokeCollectorThawingPeriod; + emit ThawCollector(msg.sender, dataService); + } + + // cancel thawing a data service's collector authorization + function cancelThawCollector(address dataService) external { + if (authorizedCollectors[msg.sender][dataService].thawEndTimestamp == 0) { + revert GraphEscrowNotThawing(); + } + + authorizedCollectors[msg.sender][dataService].thawEndTimestamp = 0; + emit CancelThawCollector(msg.sender, dataService); + } + + // revoke authorized collector + function revokeCollector(address dataService) external { + Collector storage collector = authorizedCollectors[msg.sender][dataService]; + + if (collector.thawEndTimestamp == 0) { + revert GraphEscrowNotThawing(); + } + + if (collector.thawEndTimestamp > block.timestamp) { + revert GraphEscrowStillThawing(block.timestamp, collector.thawEndTimestamp); + } + + delete authorizedCollectors[msg.sender][dataService]; + emit RevokeCollector(msg.sender, dataService); + } + + // Deposit funds into the escrow for a receiver + function deposit(address receiver, uint256 amount) external { + escrowAccounts[msg.sender][receiver].balance += amount; + TokenUtils.pullTokens(IGraphToken(GRAPH_TOKEN), msg.sender, amount); + emit Deposit(msg.sender, receiver, amount); + } + + // Deposit funds into the escrow for multiple receivers + function depositMany(address[] calldata receivers, uint256[] calldata amounts) external { + if (receivers.length != amounts.length) { + revert GraphEscrowInputsLengthMismatch(); + } + + uint256 totalAmount = 0; + for (uint256 i = 0; i < receivers.length; i++) { + address receiver = receivers[i]; + uint256 amount = amounts[i]; + + totalAmount += amount; + escrowAccounts[msg.sender][receiver].balance += amount; + emit Deposit(msg.sender, receiver, amount); + } + + TokenUtils.pullTokens(IGraphToken(GRAPH_TOKEN), msg.sender, totalAmount); + } + + // Requests to thaw a specific amount of escrow from a receiver's escrow account + function thaw(address receiver, uint256 amount) external { + EscrowAccount storage account = escrowAccounts[msg.sender][receiver]; + if (amount == 0) { + // if amount thawing is zero and requested amount is zero this is an invalid request. + // otherwise if amount thawing is greater than zero and requested amount is zero this + // is a cancel thaw request. + if (account.amountThawing == 0) { + revert GraphEscrowInsufficientThawAmount(); + } + account.amountThawing = 0; + account.thawEndTimestamp = 0; + emit CancelThaw(msg.sender, receiver); + return; + } + + // Check if the escrow balance is sufficient + if (account.balance < amount) { + revert GraphEscrowInsufficientAmount({ available: account.balance, required: amount }); + } + + // Set amount to thaw + account.amountThawing = amount; + // Set when the thaw is complete (thawing period number of seconds after current timestamp) + account.thawEndTimestamp = block.timestamp + withdrawEscrowThawingPeriod; + + emit Thaw(msg.sender, receiver, amount, account.amountThawing, account.thawEndTimestamp); + } + + // Withdraws all thawed escrow from a receiver's escrow account + function withdraw(address receiver) external { + EscrowAccount storage account = escrowAccounts[msg.sender][receiver]; + if (account.thawEndTimestamp == 0) { + revert GraphEscrowNotThawing(); + } + + if (account.thawEndTimestamp > block.timestamp) { + revert GraphEscrowStillThawing({ + currentTimestamp: block.timestamp, + thawEndTimestamp: account.thawEndTimestamp + }); + } + + // Amount is the minimum between the amount being thawed and the actual balance + uint256 amount = account.amountThawing > account.balance ? account.balance : account.amountThawing; + + account.balance -= amount; // Reduce the balance by the withdrawn amount (no underflow risk) + account.amountThawing = 0; + account.thawEndTimestamp = 0; + TokenUtils.pushTokens(IGraphToken(GRAPH_TOKEN), msg.sender, amount); + emit Withdraw(msg.sender, receiver, amount); + } + + // Collect from escrow for a receiver using sender's deposit + function collect( + address sender, + address receiver, // serviceProvider + address dataService, + uint256 amount, + IGraphPayments.PaymentTypes paymentType, + uint256 tokensDataService + ) external { + // Check if collector is authorized and has enough funds + Collector storage collector = authorizedCollectors[sender][msg.sender]; + + if (!collector.authorized) { + revert GraphEscrowCollectorNotAuthorized(sender, msg.sender); + } + + if (collector.amount < amount) { + revert GraphEscrowCollectorInsufficientAmount(collector.amount, amount); + } + + // Reduce amount from approved collector + collector.amount -= amount; + + // Collect tokens from GraphEscrow up to amount available + EscrowAccount storage account = escrowAccounts[sender][receiver]; + uint256 availableAmount = account.balance - account.amountThawing; + if (availableAmount < amount) { + revert GraphEscrowInsufficientAmount(availableAmount, amount); + } + + account.balance -= amount; + emit Collect(sender, receiver, amount); + + // Approve tokens so GraphPayments can pull them + IGraphToken graphToken = IGraphToken(GRAPH_TOKEN); + IGraphPayments graphPayments = IGraphPayments(GRAPH_PAYMENTS); + graphToken.approve(address(graphPayments), amount); + graphPayments.collect(receiver, dataService, amount, paymentType, tokensDataService); + } + + // Get the balance of a sender-receiver pair + function getBalance(address sender, address receiver) external view returns (uint256) { + EscrowAccount storage account = escrowAccounts[sender][receiver]; + return account.balance - account.amountThawing; + } +} diff --git a/packages/horizon/contracts/escrow/GraphEscrowStorage.sol b/packages/horizon/contracts/escrow/GraphEscrowStorage.sol new file mode 100644 index 000000000..158b7fae0 --- /dev/null +++ b/packages/horizon/contracts/escrow/GraphEscrowStorage.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.24; + +import { IGraphEscrow } from "../interfaces/IGraphEscrow.sol"; + +contract GraphEscrowStorageV1Storage { + // Authorized collectors + mapping(address sender => mapping(address dataService => IGraphEscrow.Collector collector)) + public authorizedCollectors; + + // Stores how much escrow each sender has deposited for each receiver, as well as thawing information + mapping(address sender => mapping(address receiver => IGraphEscrow.EscrowAccount escrowAccount)) + public escrowAccounts; + + // The maximum thawing period (in seconds) for both escrow withdrawal and signer revocation + // This is a precautionary measure to avoid inadvertedly locking funds for too long + uint256 public constant MAX_THAWING_PERIOD = 90 days; + + // Thawing period for authorized collectors + uint256 public immutable revokeCollectorThawingPeriod; + + // The duration (in seconds) in which escrow funds are thawing before they can be withdrawn + uint256 public immutable withdrawEscrowThawingPeriod; +} diff --git a/packages/horizon/contracts/interfaces/IGraphEscrow.sol b/packages/horizon/contracts/interfaces/IGraphEscrow.sol index 111d667b2..9b638a87e 100644 --- a/packages/horizon/contracts/interfaces/IGraphEscrow.sol +++ b/packages/horizon/contracts/interfaces/IGraphEscrow.sol @@ -1,6 +1,43 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity ^0.8.24; +import { IGraphPayments } from "./IGraphPayments.sol"; + interface IGraphEscrow { - function getSender(address signer) external view returns (address sender); + struct EscrowAccount { + uint256 balance; // Total escrow balance for a sender-receiver pair + uint256 amountThawing; // Amount of escrow currently being thawed + uint256 thawEndTimestamp; // Timestamp at which thawing period ends (zero if not thawing) + } + + // Collector + struct Collector { + bool authorized; + uint256 amount; + uint256 thawEndTimestamp; + } + + // Deposit funds into the escrow for a receiver + function deposit(address receiver, uint256 amount) external; + + // Deposit funds into the escrow for multiple receivers + function depositMany(address[] calldata receivers, uint256[] calldata amounts) external; + + // Requests to thaw a specific amount of escrow from a receiver's escrow account + function thaw(address receiver, uint256 amount) external; + + // Withdraws all thawed escrow from a receiver's escrow account + function withdraw(address receiver) external; + + // Collect from escrow for a receiver using sender's deposit + function collect( + address sender, + address receiver, + address dataService, + uint256 amount, + IGraphPayments.PaymentTypes paymentType, + uint256 tokensDataService + ) external; + + function getBalance(address sender, address receiver) external view returns (uint256); } diff --git a/packages/horizon/contracts/interfaces/IGraphPayments.sol b/packages/horizon/contracts/interfaces/IGraphPayments.sol index 0fa08fa63..0beda3543 100644 --- a/packages/horizon/contracts/interfaces/IGraphPayments.sol +++ b/packages/horizon/contracts/interfaces/IGraphPayments.sol @@ -2,16 +2,18 @@ pragma solidity ^0.8.24; interface IGraphPayments { + // Payment types enum PaymentTypes { QueryFee, IndexingFee } + // collect funds from a sender, pay cuts and forward the rest to the receiver function collect( - address sender, address receiver, + address dataService, uint256 tokens, PaymentTypes paymentType, uint256 tokensDataService - ) external returns (uint256); + ) external; } diff --git a/packages/horizon/contracts/mocks/MockGRTToken.sol b/packages/horizon/contracts/mocks/MockGRTToken.sol new file mode 100644 index 000000000..a39faaed3 --- /dev/null +++ b/packages/horizon/contracts/mocks/MockGRTToken.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@graphprotocol/contracts/contracts/token/IGraphToken.sol"; + +contract MockGRTToken is ERC20, IGraphToken { + constructor() ERC20("Graph Token", "GRT") {} + + function burn(uint256 amount) external {} + + function burnFrom(address _from, uint256 amount) external { + _burn(_from, amount); + } + + function mint(address to, uint256 amount) public { + _mint(to, amount); + } + + // -- Mint Admin -- + + function addMinter(address _account) external {} + + function removeMinter(address _account) external {} + + function renounceMinter() external {} + + function isMinter(address _account) external view returns (bool) {} + + // -- Permit -- + + function permit( + address _owner, + address _spender, + uint256 _value, + uint256 _deadline, + uint8 _v, + bytes32 _r, + bytes32 _s + ) external {} + + // -- Allowance -- + + function increaseAllowance(address spender, uint256 addedValue) external returns (bool) {} + function decreaseAllowance(address spender, uint256 subtractedValue) external returns (bool) {} +} diff --git a/packages/horizon/contracts/payments/GraphPayments.sol b/packages/horizon/contracts/payments/GraphPayments.sol new file mode 100644 index 000000000..2a2fa96ba --- /dev/null +++ b/packages/horizon/contracts/payments/GraphPayments.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.24; + +import { IGraphToken } from "../interfaces/IGraphToken.sol"; +import { IHorizonStaking } from "../interfaces/IHorizonStaking.sol"; +import { IGraphPayments } from "../interfaces/IGraphPayments.sol"; +import { GraphDirectory } from "../GraphDirectory.sol"; +import { GraphPaymentsStorageV1Storage } from "./GraphPaymentsStorage.sol"; +import { TokenUtils } from "../libraries/TokenUtils.sol"; + +contract GraphPayments is IGraphPayments, GraphPaymentsStorageV1Storage, GraphDirectory { + // -- Errors -- + + error GraphPaymentsNotThawing(); + error GraphPaymentsStillThawing(uint256 currentTimestamp, uint256 thawEndTimestamp); + error GraphPaymentsCollectorNotAuthorized(address sender, address dataService); + error GraphPaymentsCollectorInsufficientAmount(uint256 available, uint256 required); + + // -- Events -- + + // -- Modifier -- + + // -- Parameters -- + + uint256 private immutable MAX_PPM = 1000000; // 100% in parts per million + + // -- Constructor -- + + constructor(address _controller, uint256 _protocolPaymentCut) GraphDirectory(_controller) { + protocolPaymentCut = _protocolPaymentCut; + } + + // collect funds from a sender, pay cuts and forward the rest to the receiver + function collect( + address receiver, // serviceProvider + address dataService, + uint256 amount, + IGraphPayments.PaymentTypes paymentType, + uint256 tokensDataService + ) external { + IGraphToken graphToken = IGraphToken(GRAPH_TOKEN); + IHorizonStaking staking = IHorizonStaking(STAKING); + TokenUtils.pullTokens(graphToken, msg.sender, amount); + + // Pay protocol cut + uint256 tokensProtocol = (amount * protocolPaymentCut) / MAX_PPM; + TokenUtils.burnTokens(graphToken, tokensProtocol); + + // Pay data service cut + TokenUtils.pushTokens(graphToken, dataService, tokensDataService); + + // Get delegation cut + uint256 delegationFeeCut = staking.getDelegationFeeCut(receiver, dataService, uint8(paymentType)); + uint256 tokensDelegationPool = (amount * delegationFeeCut) / MAX_PPM; + staking.addToDelegationPool(receiver, dataService, tokensDelegationPool); + + // Pay the rest to the receiver + uint256 tokensReceiver = amount - tokensProtocol - tokensDataService - tokensDelegationPool; + TokenUtils.pushTokens(graphToken, receiver, tokensReceiver); + } +} diff --git a/packages/horizon/contracts/payments/GraphPaymentsStorage.sol b/packages/horizon/contracts/payments/GraphPaymentsStorage.sol new file mode 100644 index 000000000..7d4c1c338 --- /dev/null +++ b/packages/horizon/contracts/payments/GraphPaymentsStorage.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.24; + +import { IGraphPayments } from "../interfaces/IGraphPayments.sol"; + +contract GraphPaymentsStorageV1Storage { + // The graph protocol payment cut + uint256 public immutable protocolPaymentCut; +} diff --git a/packages/horizon/foundry.toml b/packages/horizon/foundry.toml index 38d41a40d..9f5c3a92a 100644 --- a/packages/horizon/foundry.toml +++ b/packages/horizon/foundry.toml @@ -4,5 +4,6 @@ out = 'build' libs = ['node_modules', 'lib'] test = 'test' cache_path = 'cache_forge' +fs_permissions = [{ access = "read", path = "./"}] optimizer = true optimizer-runs = 200 diff --git a/packages/horizon/lib/forge-std b/packages/horizon/lib/forge-std index bb4ceea94..e4aef94c1 160000 --- a/packages/horizon/lib/forge-std +++ b/packages/horizon/lib/forge-std @@ -1 +1 @@ -Subproject commit bb4ceea94d6f10eeb5b41dc2391c6c8bf8e734ef +Subproject commit e4aef94c1768803a16fe19f7ce8b65defd027cfd diff --git a/packages/horizon/test/GraphBase.t.sol b/packages/horizon/test/GraphBase.t.sol new file mode 100644 index 000000000..28fa8f2c8 --- /dev/null +++ b/packages/horizon/test/GraphBase.t.sol @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +import { Controller } from "@graphprotocol/contracts/contracts/governance/Controller.sol"; + +import { GraphEscrow } from "contracts/escrow/GraphEscrow.sol"; +import { GraphPayments } from "contracts/payments/GraphPayments.sol"; +import { IHorizonStaking } from "contracts/interfaces/IHorizonStaking.sol"; +import { HorizonStaking } from "contracts/staking/HorizonStaking.sol"; +import { HorizonStakingExtension } from "contracts/staking/HorizonStakingExtension.sol"; +import { MockGRTToken } from "../contracts/mocks/MockGRTToken.sol"; +import { Constants } from "./utils/Constants.sol"; +import { Users } from "./utils/Users.sol"; + +abstract contract GraphBaseTest is Test, Constants { + + /* Contracts */ + + Controller public controller; + MockGRTToken public token; + GraphPayments public payments; + GraphEscrow public escrow; + IHorizonStaking public staking; + + HorizonStaking private stakingBase; + HorizonStakingExtension private stakingExtension; + + address subgraphDataServiceAddress = makeAddr("subgraphDataServiceAddress"); + + /* Users */ + + Users internal users; + + /* Constants */ + + Constants public constants; + + /* Set Up */ + + function setUp() public virtual { + // Deploy ERC20 token + token = new MockGRTToken(); + + // Setup Users + users = Users({ + governor: createUser("governor"), + deployer: createUser("deployer"), + indexer: createUser("indexer"), + operator: createUser("operator"), + gateway: createUser("gateway"), + verifier: createUser("verifier") + }); + + // Deploy protocol contracts + deployProtocolContracts(); + unpauseProtocol(); + + // Label contracts + vm.label({ account: address(controller), newLabel: "Controller" }); + vm.label({ account: address(token), newLabel: "GraphToken" }); + vm.label({ account: address(payments), newLabel: "GraphPayments" }); + vm.label({ account: address(escrow), newLabel: "GraphEscrow" }); + vm.label({ account: address(staking), newLabel: "HorizonStaking" }); + vm.label({ account: address(stakingExtension), newLabel: "HorizonStakingExtension" }); + } + + function deployProtocolContracts() private { + vm.prank(users.governor); + controller = new Controller(); + + // GraphPayments preddict address + bytes32 saltPayments = keccak256("GraphPaymentsSalt"); + bytes32 paymentsHash = keccak256(bytes.concat( + vm.getCode("GraphPayments.sol:GraphPayments"), + abi.encode(address(controller), protocolPaymentCut) + )); + address predictedPaymentsAddress = vm.computeCreate2Address( + saltPayments, + paymentsHash, + users.deployer + ); + + // GraphEscrow preddict address + bytes32 saltEscrow = keccak256("GraphEscrowSalt"); + bytes32 escrowHash = keccak256(bytes.concat( + vm.getCode("GraphEscrow.sol:GraphEscrow"), + abi.encode( + address(controller), + revokeCollectorThawingPeriod, + withdrawEscrowThawingPeriod + ) + )); + address predictedAddressEscrow = vm.computeCreate2Address( + saltEscrow, + escrowHash, + users.deployer + ); + + // HorizonStakingExtension preddict address + bytes32 saltHorizonStakingExtension = keccak256("HorizonStakingExtensionSalt"); + bytes32 horizonStakingExtensionHash = keccak256(bytes.concat( + vm.getCode("HorizonStakingExtension.sol:HorizonStakingExtension"), + abi.encode(address(controller), subgraphDataServiceAddress) + )); + address predictedAddressHorizonStakingExtension = vm.computeCreate2Address( + saltHorizonStakingExtension, + horizonStakingExtensionHash, + users.deployer + ); + + // HorizonStaking preddict address + bytes32 saltHorizonStaking = keccak256("saltHorizonStaking"); + bytes32 horizonStakingHash = keccak256(bytes.concat( + vm.getCode("HorizonStaking.sol:HorizonStaking"), + abi.encode( + address(controller), + predictedAddressHorizonStakingExtension, + subgraphDataServiceAddress + ) + )); + // address predictedAddressHorizonStaking = vm.computeCreate2Address( + // saltHorizonStaking, + // horizonStakingHash, + // users.deployer + // ); + + // Setup controller + vm.startPrank(users.governor); + controller.setContractProxy(keccak256("GraphToken"), address(token)); + controller.setContractProxy(keccak256("GraphEscrow"), predictedAddressEscrow); + controller.setContractProxy(keccak256("GraphPayments"), predictedPaymentsAddress); + // controller.setContractProxy(keccak256("Staking"), predictedAddressHorizonStaking); + vm.stopPrank(); + + vm.startPrank(users.deployer); + payments = new GraphPayments{salt: saltPayments}( + address(controller), + protocolPaymentCut + ); + escrow = new GraphEscrow{salt: saltEscrow}( + address(controller), + revokeCollectorThawingPeriod, + withdrawEscrowThawingPeriod + ); + stakingBase = new HorizonStaking{salt: saltHorizonStaking}( + address(controller), + predictedAddressHorizonStakingExtension, + subgraphDataServiceAddress + ); + staking = IHorizonStaking(address(stakingBase)); + // stakingExtension = new HorizonStakingExtension{salt: saltHorizonStakingExtension}( + // address(controller), + // subgraphDataServiceAddress + // ); + vm.stopPrank(); + } + + function unpauseProtocol() private { + vm.prank(users.governor); + controller.setPaused(false); + } + + function createUser(string memory name) private returns (address) { + address user = makeAddr(name); + vm.deal({ account: user, newBalance: 100 ether }); + deal({ token: address(token), to: user, give: type(uint256).max }); + vm.label({ account: user, newLabel: name }); + return user; + } + + /* Token helpers */ + + function mint(address _address, uint256 amount) internal { + deal({ token: address(token), to: _address, give: amount }); + } + + function approve(address spender, uint256 amount) internal { + token.approve(spender, amount); + } +} \ No newline at end of file diff --git a/packages/horizon/test/deployments/Deployments.t.sol b/packages/horizon/test/deployments/Deployments.t.sol new file mode 100644 index 000000000..37fe8b237 --- /dev/null +++ b/packages/horizon/test/deployments/Deployments.t.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +import { GraphBaseTest } from "../GraphBase.t.sol"; + +contract GraphDeploymentsTest is GraphBaseTest { + + function testDeployments() public view { + assertEq(address(escrow.GRAPH_PAYMENTS()), address(payments)); + assertEq(address(escrow.GRAPH_TOKEN()), address(token)); + assertEq(address(payments.STAKING()), address(staking)); + assertEq(address(payments.GRAPH_ESCROW()), address(escrow)); + assertEq(address(payments.GRAPH_TOKEN()), address(token)); + } +} diff --git a/packages/horizon/test/escrow/GraphEscrow.t.sol b/packages/horizon/test/escrow/GraphEscrow.t.sol new file mode 100644 index 000000000..f420c1964 --- /dev/null +++ b/packages/horizon/test/escrow/GraphEscrow.t.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +import { HorizonStakingSharedTest } from "../shared/horizon-staking/HorizonStaking.t.sol"; + +contract GraphEscrowTest is HorizonStakingSharedTest { + + modifier useGateway() { + vm.startPrank(users.gateway); + _; + vm.stopPrank(); + } + + modifier approveEscrow(uint256 amount) { + _approveEscrow(amount); + _; + } + + modifier depositTokens(uint256 amount) { + vm.assume(amount > 0); + vm.assume(amount <= 10000 ether); + _depositTokens(amount); + _; + } + + function setUp() public virtual override { + HorizonStakingSharedTest.setUp(); + } + + function _depositTokens(uint256 amount) internal { + token.approve(address(escrow), amount); + escrow.deposit(users.indexer, amount); + } + + function _approveEscrow(uint256 amount) internal { + token.approve(address(escrow), amount); + } +} \ No newline at end of file diff --git a/packages/horizon/test/escrow/collect.t.sol b/packages/horizon/test/escrow/collect.t.sol new file mode 100644 index 000000000..604e1b4ee --- /dev/null +++ b/packages/horizon/test/escrow/collect.t.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +import { GraphEscrowTest } from "./GraphEscrow.t.sol"; +import { IGraphPayments } from "../../contracts/interfaces/IGraphPayments.sol"; + +contract GraphEscrowCollectTest is GraphEscrowTest { + + function testCollect() public { + uint256 amount = 1000 ether; + createProvision(amount); + setDelegationFeeCut(0, 100000); + + vm.startPrank(users.gateway); + escrow.approveCollector(users.verifier, 1000 ether); + token.approve(address(escrow), 1000 ether); + escrow.deposit(users.indexer, 1000 ether); + vm.stopPrank(); + + uint256 indexerPreviousBalance = token.balanceOf(users.indexer); + vm.prank(users.verifier); + escrow.collect(users.gateway, users.indexer, subgraphDataServiceAddress, 100 ether, IGraphPayments.PaymentTypes.IndexingFee, 3 ether); + + uint256 indexerBalance = token.balanceOf(users.indexer); + assertEq(indexerBalance - indexerPreviousBalance, 86 ether); + } + + function testCollect_RevertWhen_CollectorNotAuthorized() public { + address indexer = address(0xA3); + uint256 amount = 1000 ether; + + vm.startPrank(users.verifier); + uint256 dataServiceCut = 30000; // 3% + bytes memory expectedError = abi.encodeWithSignature("GraphEscrowCollectorNotAuthorized(address,address)", users.gateway, users.verifier); + vm.expectRevert(expectedError); + escrow.collect(users.gateway, indexer, subgraphDataServiceAddress, amount, IGraphPayments.PaymentTypes.IndexingFee, dataServiceCut); + vm.stopPrank(); + } + + function testCollect_RevertWhen_CollectorHasInsufficientAmount() public { + vm.prank(users.gateway); + escrow.approveCollector(users.verifier, 100 ether); + + address indexer = address(0xA3); + uint256 amount = 1000 ether; + + vm.startPrank(users.gateway); + token.approve(address(escrow), amount); + escrow.deposit(indexer, amount); + vm.stopPrank(); + + vm.startPrank(users.verifier); + uint256 dataServiceCut = 30 ether; + bytes memory expectedError = abi.encodeWithSignature("GraphEscrowCollectorInsufficientAmount(uint256,uint256)", 100 ether, 1000 ether); + vm.expectRevert(expectedError); + escrow.collect(users.gateway, indexer, subgraphDataServiceAddress, 1000 ether, IGraphPayments.PaymentTypes.IndexingFee, dataServiceCut); + vm.stopPrank(); + } + + function testCollect_RevertWhen_SenderHasInsufficientAmountInEscrow() public { + vm.startPrank(users.gateway); + escrow.approveCollector(users.verifier, 1000 ether); + token.approve(address(escrow), 1000 ether); + escrow.deposit(users.indexer, 100 ether); + vm.stopPrank(); + + vm.prank(users.verifier); + bytes memory expectedError = abi.encodeWithSignature("GraphEscrowInsufficientAmount(uint256,uint256)", 100 ether, 200 ether); + vm.expectRevert(expectedError); + escrow.collect(users.gateway, users.indexer, subgraphDataServiceAddress, 200 ether, IGraphPayments.PaymentTypes.IndexingFee, 3 ether); + vm.stopPrank(); + } +} \ No newline at end of file diff --git a/packages/horizon/test/escrow/collector.t.sol b/packages/horizon/test/escrow/collector.t.sol new file mode 100644 index 000000000..f4ef2b11c --- /dev/null +++ b/packages/horizon/test/escrow/collector.t.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +import { GraphEscrowTest } from "./GraphEscrow.t.sol"; + +contract GraphEscrowCollectorTest is GraphEscrowTest { + function setUp() public virtual override { + GraphEscrowTest.setUp(); + vm.prank(users.gateway); + escrow.approveCollector(users.verifier, 1000 ether); + } + + // Collector approve tests + + function testCollector_Approve() public view { + (bool authorized,, uint256 thawEndTimestamp) = escrow.authorizedCollectors(users.gateway, users.verifier); + assertEq(authorized, true); + assertEq(thawEndTimestamp, 0); + } + + // Collector thaw tests + + function testCollector_Thaw() public { + vm.prank(users.gateway); + escrow.thawCollector(users.verifier); + + (bool authorized,, uint256 thawEndTimestamp) = escrow.authorizedCollectors(users.gateway, users.verifier); + assertEq(authorized, true); + assertEq(thawEndTimestamp, block.timestamp + revokeCollectorThawingPeriod); + } + + // Collector cancel thaw tests + + function testCollector_CancelThaw() public { + vm.prank(users.gateway); + escrow.thawCollector(users.verifier); + + (bool authorized,, uint256 thawEndTimestamp) = escrow.authorizedCollectors(users.gateway, users.verifier); + assertEq(authorized, true); + assertEq(thawEndTimestamp, block.timestamp + revokeCollectorThawingPeriod); + + vm.prank(users.gateway); + escrow.cancelThawCollector(users.verifier); + + (authorized,, thawEndTimestamp) = escrow.authorizedCollectors(users.gateway, users.verifier); + assertEq(authorized, true); + assertEq(thawEndTimestamp, 0); + } + + function testCollector_RevertWhen_CancelThawIsNotThawing() public { + bytes memory expectedError = abi.encodeWithSignature("GraphEscrowNotThawing()"); + vm.expectRevert(expectedError); + escrow.cancelThawCollector(users.verifier); + vm.stopPrank(); + } + + // Collector revoke tests + + function testCollector_Revoke() public { + vm.startPrank(users.gateway); + escrow.thawCollector(users.verifier); + skip(revokeCollectorThawingPeriod + 1); + escrow.revokeCollector(users.verifier); + vm.stopPrank(); + + (bool authorized,,) = escrow.authorizedCollectors(users.gateway, users.verifier); + assertEq(authorized, false); + } + + function testCollector_RevertWhen_RevokeIsNotThawing() public { + bytes memory expectedError = abi.encodeWithSignature("GraphEscrowNotThawing()"); + vm.expectRevert(expectedError); + vm.prank(users.gateway); + escrow.revokeCollector(users.verifier); + } + + function testCollector_RevertWhen_RevokeIsStillThawing() public { + vm.startPrank(users.gateway); + escrow.thawCollector(users.verifier); + bytes memory expectedError = abi.encodeWithSignature("GraphEscrowStillThawing(uint256,uint256)", block.timestamp, block.timestamp + revokeCollectorThawingPeriod); + vm.expectRevert(expectedError); + escrow.revokeCollector(users.verifier); + vm.stopPrank(); + } +} \ No newline at end of file diff --git a/packages/horizon/test/escrow/deposit.t.sol b/packages/horizon/test/escrow/deposit.t.sol new file mode 100644 index 000000000..d7898bd37 --- /dev/null +++ b/packages/horizon/test/escrow/deposit.t.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +import { GraphEscrowTest } from "./GraphEscrow.t.sol"; + +contract GraphEscrowDepositTest is GraphEscrowTest { + + function testDeposit_Tokens(uint256 amount) public useGateway depositTokens(amount) { + (uint256 indexerEscrowBalance,,) = escrow.escrowAccounts(users.gateway, users.indexer); + assertEq(indexerEscrowBalance, amount); + } + + function testDeposit_ManyDeposits(uint256 amount) public useGateway approveEscrow(amount) { + uint256 amountOne = amount / 2; + uint256 amountTwo = amount - amountOne; + + address otherIndexer = address(0xB3); + address[] memory indexers = new address[](2); + indexers[0] = users.indexer; + indexers[1] = otherIndexer; + + uint256[] memory amounts = new uint256[](2); + amounts[0] = amountOne; + amounts[1] = amountTwo; + + escrow.depositMany(indexers, amounts); + + (uint256 indexerEscrowBalance,,) = escrow.escrowAccounts(users.gateway, users.indexer); + assertEq(indexerEscrowBalance, amountOne); + + (uint256 otherIndexerEscrowBalance,,) = escrow.escrowAccounts(users.gateway, otherIndexer); + assertEq(otherIndexerEscrowBalance, amountTwo); + } + + function testDeposit_RevertWhen_ManyDepositsInputsLengthMismatch( + uint256 amount + ) public useGateway approveEscrow(amount) { + address otherIndexer = address(0xB3); + address[] memory indexers = new address[](2); + indexers[0] = users.indexer; + indexers[1] = otherIndexer; + + uint256[] memory amounts = new uint256[](1); + amounts[0] = 1000 ether; + + // revert + bytes memory expectedError = abi.encodeWithSignature("GraphEscrowInputsLengthMismatch()"); + vm.expectRevert(expectedError); + escrow.depositMany(indexers, amounts); + } +} \ No newline at end of file diff --git a/packages/horizon/test/escrow/thaw.t.sol b/packages/horizon/test/escrow/thaw.t.sol new file mode 100644 index 000000000..51400feac --- /dev/null +++ b/packages/horizon/test/escrow/thaw.t.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +import { GraphEscrowTest } from "./GraphEscrow.t.sol"; + +contract GraphEscrowThawTest is GraphEscrowTest { + + function testThaw_Tokens(uint256 amount) public useGateway depositTokens(amount) { + escrow.thaw(users.indexer, amount); + + (, uint256 amountThawing,uint256 thawEndTimestamp) = escrow.escrowAccounts(users.gateway, users.indexer); + assertEq(amountThawing, amount); + assertEq(thawEndTimestamp, block.timestamp + withdrawEscrowThawingPeriod); + } + + function testThaw_RevertWhen_InsufficientThawAmount( + uint256 amount + ) public useGateway depositTokens(amount) { + bytes memory expectedError = abi.encodeWithSignature("GraphEscrowInsufficientThawAmount()"); + vm.expectRevert(expectedError); + escrow.thaw(users.indexer, 0); + } + + function testThaw_RevertWhen_InsufficientAmount( + uint256 amount + ) public useGateway depositTokens(amount) { + uint256 overAmount = amount + 1; + bytes memory expectedError = abi.encodeWithSignature("GraphEscrowInsufficientAmount(uint256,uint256)", amount, overAmount); + vm.expectRevert(expectedError); + escrow.thaw(users.indexer, overAmount); + } +} \ No newline at end of file diff --git a/packages/horizon/test/escrow/withdraw.t.sol b/packages/horizon/test/escrow/withdraw.t.sol new file mode 100644 index 000000000..075a16a74 --- /dev/null +++ b/packages/horizon/test/escrow/withdraw.t.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +import { GraphEscrowTest } from "./GraphEscrow.t.sol"; + +contract GraphEscrowWithdrawTest is GraphEscrowTest { + + modifier depositAndThawTokens(uint256 amount, uint256 thawAmount) { + vm.assume(thawAmount > 0); + vm.assume(amount > thawAmount); + _depositTokens(amount); + escrow.thaw(users.indexer, thawAmount); + _; + } + + function testWithdraw_Tokens( + uint256 amount, + uint256 thawAmount + ) public useGateway depositAndThawTokens(amount, thawAmount) { + // advance time + skip(withdrawEscrowThawingPeriod + 1); + + escrow.withdraw(users.indexer); + vm.stopPrank(); + + (uint256 indexerEscrowBalance,,) = escrow.escrowAccounts(users.gateway, users.indexer); + assertEq(indexerEscrowBalance, amount - thawAmount); + } + + function testWithdraw_RevertWhen_NotThawing(uint256 amount) public useGateway depositTokens(amount) { + bytes memory expectedError = abi.encodeWithSignature("GraphEscrowNotThawing()"); + vm.expectRevert(expectedError); + escrow.withdraw(users.indexer); + vm.stopPrank(); + } + + function testWithdraw_RevertWhen_StillThawing( + uint256 amount, + uint256 thawAmount + ) public useGateway depositAndThawTokens(amount, thawAmount) { + bytes memory expectedError = abi.encodeWithSignature("GraphEscrowStillThawing(uint256,uint256)", block.timestamp, block.timestamp + withdrawEscrowThawingPeriod); + vm.expectRevert(expectedError); + escrow.withdraw(users.indexer); + vm.stopPrank(); + } +} \ No newline at end of file diff --git a/packages/horizon/test/payments/GraphPayments.t.sol b/packages/horizon/test/payments/GraphPayments.t.sol new file mode 100644 index 000000000..73f8f431b --- /dev/null +++ b/packages/horizon/test/payments/GraphPayments.t.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +import { IGraphPayments } from "../../contracts/interfaces/IGraphPayments.sol"; + +import { HorizonStakingSharedTest } from "../shared/horizon-staking/HorizonStaking.t.sol"; + +contract GraphPaymentsTest is HorizonStakingSharedTest { + + function testCollect() public { + // Setup Staking + uint256 amount = 1000 ether; + createProvision(amount); + setDelegationFeeCut(0, 100000); + + address escrowAddress = address(escrow); + + // Add tokens in escrow + mint(escrowAddress, amount); + vm.startPrank(escrowAddress); + approve(address(payments), amount); + + // Collect payments through GraphPayments + uint256 dataServiceCut = 30 ether; // 3% + uint256 indexerPreviousBalance = token.balanceOf(users.indexer); + payments.collect(users.indexer, subgraphDataServiceAddress, amount, IGraphPayments.PaymentTypes.IndexingFee, dataServiceCut); + vm.stopPrank(); + + uint256 indexerBalance = token.balanceOf(users.indexer); + assertEq(indexerBalance - indexerPreviousBalance, 860 ether); + + uint256 dataServiceBalance = token.balanceOf(subgraphDataServiceAddress); + assertEq(dataServiceBalance, 30 ether); + + uint256 delegatorBalance = staking.getDelegatedTokensAvailable(users.indexer, subgraphDataServiceAddress); + assertEq(delegatorBalance, 100 ether); + } +} diff --git a/packages/horizon/test/shared/horizon-staking/HorizonStaking.t.sol b/packages/horizon/test/shared/horizon-staking/HorizonStaking.t.sol new file mode 100644 index 000000000..c5aec8aa2 --- /dev/null +++ b/packages/horizon/test/shared/horizon-staking/HorizonStaking.t.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +import { GraphBaseTest } from "../../GraphBase.t.sol"; + +abstract contract HorizonStakingSharedTest is GraphBaseTest { + + /* Set Up */ + + function setUp() public virtual override { + GraphBaseTest.setUp(); + } + + /* Helpers */ + + function createProvision(uint256 tokens) internal { + vm.startPrank(users.indexer); + token.approve(address(staking), tokens); + staking.stakeTo(users.indexer, tokens); + staking.provision(users.indexer, subgraphDataServiceAddress, tokens, 0, 0); + } + + function setDelegationFeeCut(uint256 paymentType, uint256 cut) internal { + staking.setDelegationFeeCut(users.indexer, subgraphDataServiceAddress, paymentType, cut); + } +} diff --git a/packages/horizon/test/utils/Constants.sol b/packages/horizon/test/utils/Constants.sol new file mode 100644 index 000000000..8e03271ec --- /dev/null +++ b/packages/horizon/test/utils/Constants.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +abstract contract Constants { + // GraphEscrow parameters + uint256 internal constant withdrawEscrowThawingPeriod = 60; + // GraphPayments parameters + uint256 internal constant revokeCollectorThawingPeriod = 60; + uint256 internal constant protocolPaymentCut = 10000; +} \ No newline at end of file diff --git a/packages/horizon/test/utils/Users.sol b/packages/horizon/test/utils/Users.sol new file mode 100644 index 000000000..39f1a2a5f --- /dev/null +++ b/packages/horizon/test/utils/Users.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +struct Users { + address governor; + address deployer; + address indexer; + address operator; + address gateway; + address verifier; +} \ No newline at end of file