diff --git a/src/bridge/BridgeBase.sol b/src/bridge/BridgeBase.sol index 0bfbd24..42b50b4 100644 --- a/src/bridge/BridgeBase.sol +++ b/src/bridge/BridgeBase.sol @@ -10,7 +10,7 @@ import {NODL} from "../NODL.sol"; /// to bridge tokens and ensuring certain constraints such as voting thresholds and delays. abstract contract BridgeBase { /// @notice Token contract address for the NODL token. - NODL public nodl; + NODL public immutable nodl; /// @notice Mapping to track whether an address is an oracle. mapping(address => bool) public isOracle; diff --git a/src/bridge/GrantsMigration.sol b/src/bridge/GrantsMigration.sol new file mode 100644 index 0000000..76f3f01 --- /dev/null +++ b/src/bridge/GrantsMigration.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity 0.8.23; + +import {Grants} from "../Grants.sol"; +import {NODL} from "../NODL.sol"; +import {BridgeBase} from "./BridgeBase.sol"; + +contract GrantsMigration is BridgeBase { + /// @dev Represents the vesting details of a proposal. + struct Proposal { + address target; // Address of the grant recipient. + uint256 amount; // Total amount of tokens to be vested. + Grants.VestingSchedule[] schedules; // Array of vesting schedules. + } + + /// @dev Tracks voting and execution status of a proposal. + struct ProposalStatus { + uint256 lastVote; // Timestamp of the last vote. + uint8 totalVotes; // Total number of votes cast. + bool executed; // Whether the proposal has been executed. + } + + // State variables + Grants public immutable grants; // Grants contract. + mapping(bytes32 => Proposal) public proposals; // Proposals identified by a hash. + mapping(bytes32 => ProposalStatus) public proposalStatus; // Status of each proposal. + + // Events + event Granted(bytes32 indexed proposal, address indexed user, uint256 amount, uint256 numOfSchedules); + + /** + * @param bridgeOracles Array of addresses authorized to initiate and vote on proposals. + * @param token Address of the NODL token used for grants. + * @param _grants Address of the Grants contract managing vesting schedules. + * @param minVotes Minimum number of votes required to execute a proposal. + * @param minDelay Minimum delay before a proposal can be executed. + */ + constructor(address[] memory bridgeOracles, NODL token, Grants _grants, uint8 minVotes, uint256 minDelay) + BridgeBase(bridgeOracles, token, minVotes, minDelay) + { + grants = _grants; + } + + /** + * @notice Bridges a proposal for grant distribution across chains or domains. + * @param paraTxHash Hash of the cross-chain transaction or parameter. + * @param user Recipient of the grant. + * @param amount Total token amount for the grant. + * @param schedules Array of VestingSchedule, detailing the vesting mechanics. + */ + function bridge(bytes32 paraTxHash, address user, uint256 amount, Grants.VestingSchedule[] memory schedules) + external + { + _mustBeAnOracle(msg.sender); + _mustNotHaveExecutedYet(paraTxHash); + + if (_proposalExists(paraTxHash)) { + _mustNotHaveVotedYet(paraTxHash, msg.sender); + _mustNotBeChangingParameters(paraTxHash, user, amount, schedules); + _recordVote(paraTxHash, msg.sender); + } else { + _createProposal(paraTxHash, msg.sender, user, amount, schedules); + } + } + + /** + * @notice Executes the grant proposal, transferring vested tokens according to the schedules. + * @param paraTxHash Hash of the proposal to be executed. + */ + function grant(bytes32 paraTxHash) external { + _execute(paraTxHash); + Proposal storage p = proposals[paraTxHash]; + nodl.mint(address(this), proposals[paraTxHash].amount); + nodl.approve(address(grants), proposals[paraTxHash].amount); + for (uint256 i = 0; i < p.schedules.length; i++) { + grants.addVestingSchedule( + p.target, + p.schedules[i].start, + p.schedules[i].period, + p.schedules[i].periodCount, + p.schedules[i].perPeriodAmount, + p.schedules[i].cancelAuthority + ); + } + emit Granted(paraTxHash, p.target, p.amount, p.schedules.length); + } + + // Internal helper functions below + + function _mustNotBeChangingParameters( + bytes32 proposal, + address user, + uint256 amount, + Grants.VestingSchedule[] memory schedules + ) internal view { + Proposal storage storedProposal = proposals[proposal]; + + if (storedProposal.amount != amount || storedProposal.target != user) { + revert ParametersChanged(proposal); + } + + uint256 len = storedProposal.schedules.length; + if (len != schedules.length) { + revert ParametersChanged(proposal); + } + + for (uint256 i = 0; i < len; i++) { + if ( + storedProposal.schedules[i].start != schedules[i].start + || storedProposal.schedules[i].period != schedules[i].period + || storedProposal.schedules[i].periodCount != schedules[i].periodCount + || storedProposal.schedules[i].perPeriodAmount != schedules[i].perPeriodAmount + || storedProposal.schedules[i].cancelAuthority != schedules[i].cancelAuthority + ) { + revert ParametersChanged(proposal); + } + } + } + + function _createProposal( + bytes32 proposal, + address oracle, + address user, + uint256 amount, + Grants.VestingSchedule[] memory schedules + ) internal { + proposals[proposal] = Proposal({target: user, amount: amount, schedules: schedules}); + super._createVote(proposal, oracle, user, amount); + } + + function _proposalExists(bytes32 proposal) internal view returns (bool) { + return proposalStatus[proposal].totalVotes > 0; + } + + function _flagAsExecuted(bytes32 proposal) internal override { + proposalStatus[proposal].executed = true; + } + + function _incTotalVotes(bytes32 proposal) internal override { + proposalStatus[proposal].totalVotes++; + } + + function _updateLastVote(bytes32 proposal, uint256 value) internal override { + proposalStatus[proposal].lastVote = value; + } + + function _totalVotes(bytes32 proposal) internal view override returns (uint8) { + return proposalStatus[proposal].totalVotes; + } + + function _lastVote(bytes32 proposal) internal view override returns (uint256) { + return proposalStatus[proposal].lastVote; + } + + function _executed(bytes32 proposal) internal view override returns (bool) { + return proposalStatus[proposal].executed; + } +} diff --git a/test/bridge/GrantsMigration.t.sol b/test/bridge/GrantsMigration.t.sol new file mode 100644 index 0000000..93aa443 --- /dev/null +++ b/test/bridge/GrantsMigration.t.sol @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.23; + +import {Test, console} from "forge-std/Test.sol"; +import {GrantsMigration} from "../../src/bridge/GrantsMigration.sol"; +import {BridgeBase} from "../../src/bridge/BridgeBase.sol"; +import {Grants} from "../../src/Grants.sol"; +import {NODL} from "../../src/NODL.sol"; + +contract GrantsMigrationTest is Test { + GrantsMigration migration; + Grants grants; + NODL nodl; + + uint256 delay = 100; + address[] oracles = [vm.addr(1), vm.addr(2), vm.addr(3)]; + address user = vm.addr(4); + Grants.VestingSchedule[] schedules; + uint256 amount = 0; + + function setUp() public { + nodl = new NODL(); + grants = new Grants(address(nodl)); + migration = new GrantsMigration(oracles, nodl, grants, 2, delay); + + schedules.push( + Grants.VestingSchedule({ + start: block.timestamp + 1 days, + period: 1 days, + periodCount: 5, + perPeriodAmount: 20, + cancelAuthority: user + }) + ); + schedules.push( + Grants.VestingSchedule({ + start: block.timestamp + 2 days, + period: 3 days, + periodCount: 7, + perPeriodAmount: 19, + cancelAuthority: oracles[0] + }) + ); + schedules.push( + Grants.VestingSchedule({ + start: block.timestamp + 7 days, + period: 11 days, + periodCount: 13, + perPeriodAmount: 37, + cancelAuthority: address(0) + }) + ); + + for (uint256 i = 0; i < schedules.length; i++) { + amount += schedules[i].perPeriodAmount * schedules[i].periodCount; + } + + nodl.grantRole(nodl.MINTER_ROLE(), address(migration)); + } + + function test_oraclesAreRegisteredProperly() public { + for (uint256 i = 0; i < oracles.length; i++) { + assertEq(migration.isOracle(oracles[i]), true); + } + assertEq(migration.threshold(), 2); + assertEq(migration.delay(), delay); + } + + function test_proposalCreationAndVoting() public { + bytes32 paraTxHash = keccak256(abi.encodePacked("tx1")); + vm.prank(oracles[0]); + vm.expectEmit(); + emit BridgeBase.VoteStarted(paraTxHash, oracles[0], user, amount); + migration.bridge(paraTxHash, user, amount, schedules); + + vm.prank(oracles[1]); + vm.expectEmit(); + emit BridgeBase.Voted(paraTxHash, oracles[1]); + migration.bridge(paraTxHash, user, amount, schedules); + + (uint256 lastVote, uint8 totalVotes, bool executed) = migration.proposalStatus(paraTxHash); + assertEq(totalVotes, 2); + assertEq(executed, false); + assertEq(lastVote, block.timestamp); + } + + function test_executionOfProposals() public { + bytes32 paraTxHash = keccak256(abi.encodePacked("tx2")); + vm.prank(oracles[0]); + migration.bridge(paraTxHash, user, amount, schedules); + + vm.prank(oracles[1]); + migration.bridge(paraTxHash, user, amount, schedules); + + vm.roll(block.number + delay + 1); + + vm.prank(oracles[0]); + vm.expectEmit(); + emit GrantsMigration.Granted(paraTxHash, user, amount, schedules.length); + migration.grant(paraTxHash); + + (,, bool executed) = migration.proposalStatus(paraTxHash); + assertEq(executed, true); + } + + function test_proposalParameterChangesPreventVoting() public { + bytes32 paraTxHash = keccak256(abi.encodePacked("tx3")); + vm.prank(oracles[0]); + migration.bridge(paraTxHash, user, amount, schedules); + + vm.expectRevert(abi.encodeWithSelector(BridgeBase.ParametersChanged.selector, paraTxHash)); + vm.prank(oracles[1]); + migration.bridge(paraTxHash, user, amount + 1, schedules); + + // Change the target address + vm.expectRevert(abi.encodeWithSelector(BridgeBase.ParametersChanged.selector, paraTxHash)); + vm.prank(oracles[1]); + migration.bridge(paraTxHash, oracles[1], amount, schedules); + + schedules[0].start += 1 days; + vm.expectRevert(abi.encodeWithSelector(BridgeBase.ParametersChanged.selector, paraTxHash)); + vm.prank(oracles[1]); + migration.bridge(paraTxHash, user, amount, schedules); + schedules[0].start -= 1 days; + + schedules[1].period += 1 days; + vm.expectRevert(abi.encodeWithSelector(BridgeBase.ParametersChanged.selector, paraTxHash)); + vm.prank(oracles[1]); + migration.bridge(paraTxHash, user, amount, schedules); + schedules[1].period -= 1 days; + + schedules[2].periodCount += 1; + vm.expectRevert(abi.encodeWithSelector(BridgeBase.ParametersChanged.selector, paraTxHash)); + vm.prank(oracles[1]); + migration.bridge(paraTxHash, user, amount, schedules); + schedules[2].periodCount -= 1; + + schedules[0].perPeriodAmount += 1; + vm.expectRevert(abi.encodeWithSelector(BridgeBase.ParametersChanged.selector, paraTxHash)); + vm.prank(oracles[1]); + migration.bridge(paraTxHash, user, amount, schedules); + schedules[0].perPeriodAmount -= 1; + + schedules[1].cancelAuthority = oracles[2]; + vm.expectRevert(abi.encodeWithSelector(BridgeBase.ParametersChanged.selector, paraTxHash)); + vm.prank(oracles[1]); + migration.bridge(paraTxHash, user, amount, schedules); + schedules[1].cancelAuthority = oracles[0]; + + Grants.VestingSchedule[] memory newSchedules = new Grants.VestingSchedule[](schedules.length - 1); + newSchedules[0] = schedules[0]; + newSchedules[1] = schedules[1]; + vm.expectRevert(abi.encodeWithSelector(BridgeBase.ParametersChanged.selector, paraTxHash)); + vm.prank(oracles[1]); + migration.bridge(paraTxHash, user, amount, newSchedules); + + vm.prank(oracles[1]); + migration.bridge(paraTxHash, user, amount, schedules); + } + + function test_rejectionOfDuplicateVotes() public { + bytes32 paraTxHash = keccak256(abi.encodePacked("tx4")); + vm.prank(oracles[0]); + migration.bridge(paraTxHash, user, amount, schedules); + + vm.expectRevert(abi.encodeWithSelector(BridgeBase.AlreadyVoted.selector, paraTxHash, oracles[0])); + vm.prank(oracles[0]); + migration.bridge(paraTxHash, user, amount, schedules); + } + + function test_rejectionOfVoteOnAlreadyExecuted() public { + bytes32 paraTxHash = keccak256(abi.encodePacked("tx5")); + vm.prank(oracles[0]); + migration.bridge(paraTxHash, user, amount, schedules); + + vm.prank(oracles[1]); + migration.bridge(paraTxHash, user, amount, schedules); + + vm.roll(block.number + delay + 1); + migration.grant(paraTxHash); + + vm.expectRevert(abi.encodeWithSelector(BridgeBase.AlreadyExecuted.selector, paraTxHash)); + vm.prank(oracles[2]); + migration.bridge(paraTxHash, user, amount, schedules); + } + + function test_executionFailsIfInsufficientVotes() public { + bytes32 paraTxHash = keccak256(abi.encodePacked("tx6")); + vm.prank(oracles[0]); + migration.bridge(paraTxHash, user, 100, schedules); + + vm.roll(block.number + delay + 1); + + vm.expectRevert(abi.encodeWithSelector(BridgeBase.NotEnoughVotes.selector, paraTxHash)); + migration.grant(paraTxHash); + } + + function test_executionFailsIfTooEarly() public { + bytes32 paraTxHash = keccak256(abi.encodePacked("tx7")); + vm.prank(oracles[0]); + migration.bridge(paraTxHash, user, 100, schedules); + + vm.prank(oracles[1]); + migration.bridge(paraTxHash, user, 100, schedules); + + vm.expectRevert(abi.encodeWithSelector(BridgeBase.NotYetWithdrawable.selector, paraTxHash)); + migration.grant(paraTxHash); + } + + function test_registeringTooManyOraclesFails() public { + uint8 max_oracles = migration.MAX_ORACLES(); + address[] memory manyOracles = new address[](max_oracles + 1); + for (uint256 i = 0; i < manyOracles.length; i++) { + manyOracles[i] = vm.addr(i + 1); + } + vm.expectRevert(abi.encodeWithSelector(BridgeBase.MaxOraclesExceeded.selector)); + new GrantsMigration(manyOracles, nodl, grants, uint8(manyOracles.length / 2 + 1), delay); + } +}