-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(GrantsMigration): allow permissioned off chain oracles to mint t…
…okens and lock them for grants (#46)
- Loading branch information
Showing
3 changed files
with
379 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
} | ||
} |