Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bulk donation support #96

Merged
merged 8 commits into from
Aug 12, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions contracts/.solhint.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
"extends": "solhint:recommended",
"plugins": ["prettier"],
"rules": {
"code-complexity": ["error", 7],
"code-complexity": ["error", 9],
apbendi marked this conversation as resolved.
Show resolved Hide resolved
"compiler-version": ["error", "^0.8.0"],
"const-name-snakecase": "off",
"constructor-syntax": "error",
"func-visibility": ["error", { "ignoreConstructors": true }],
"max-line-length": "off",
"not-rely-on-time": "off",
"prettier/prettier": ["error", { "endOfLine": "auto" }],
"reason-string": "off"
"reason-string": "off",
"reentrancy": "off"
}
}
216 changes: 148 additions & 68 deletions contracts/contracts/GrantRoundManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,12 @@ import "./GrantRegistry.sol";
import "./GrantRound.sol";

contract GrantRoundManager {
// --- Libraries ---
using Address for address;
using BytesLib for bytes;
using SafeERC20 for IERC20;

/// @notice Donation inputs and Uniswap V3 swap inputs: https://docs.uniswap.org/protocol/guides/swaps/multihop-swaps
struct Donation {
uint96 grantId; // grant ID to which donation is being made
GrantRound[] rounds; // rounds against which the donation should be counted
bytes path; // swap path, or if user is providing donationToken, the address of the donationToken
uint256 deadline; // unix timestamp after which a swap will revert, i.e. swap must be executed before this
uint256 amountIn; // amount donated by the user
uint256 amountOutMinimum; // minimum amount to be returned after swap
}

// --- Data ---
/// @notice Address of the GrantRegistry
GrantRegistry public immutable registry;

Expand All @@ -32,35 +24,70 @@ contract GrantRoundManager {
/// @notice Address of the ERC20 token in which donations are made
IERC20 public immutable donationToken;

/// @notice Used during donations to temporarily store swap output amounts
mapping(IERC20 => uint256) internal swapOutputs;
apbendi marked this conversation as resolved.
Show resolved Hide resolved

/// @notice Used during donations to temporarily store donation ratio sums to ensure totals are always 100%
mapping(IERC20 => uint256) internal donationRatios;

/// @notice WETH address
address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
IERC20 public constant WETH = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);

/// @notice Scale factor
uint256 internal constant WAD = 1e18;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decimals here may lead to misleading amounts for tokens like USDC which only go to 1e6

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, how so? Even if USDC amount where 1e6 =1 USDC, say you want 25% of 1 USDC, then you'd have 1e6 * 0.25e18 / 1e18 = 250000 = 0.25 USDC

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right but what if someone tries to swap 0.2500009 USDC and submits that to the swap method on uni won't it revert?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have an example of how we might get such a value? When we divide by 1e18 at the end, we'll either be left 250,000 (0.25 USDC) or 250,001 (0.250001 USDC) because there's only 6 decimals of precision when do you tokenDecimalsUsdc * xe18 / 1e18


/// --- Types ---
/// @notice Defines the total `amount` of the specified `token` that needs to be swapped to `donationToken`. If
/// `path == donationToken`, no swap is required and we just transfer the tokens
struct SwapSummary {
uint256 amountIn;
uint256 amountOutMin; // minimum amount to be returned after swap
bytes path;
}

/// @notice Donation inputs and Uniswap V3 swap inputs: https://docs.uniswap.org/protocol/guides/swaps/multihop-swaps
struct Donation {
uint96 grantId; // grant ID to which donation is being made
IERC20 token; // address of the token to donate
uint256 ratio; // ratio of `token` to donate, specified as numerator where WAD = 1e18 = 100%
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for swaps with different decimals we could floor the amount for the tokens with the least amount of significant figures

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems related to the above comment, but I'm not sure what you mean here and where you need to floor things that isn't already floored by integer division in Solidity?

Copy link
Contributor

@corydickson corydickson Aug 11, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe I'm missing something, but it's not related to the calculation per say but when we transfer an amount, emitting one value but really another has been sent to the contract (even if it doesnt revert)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _donationAmount that is transferred is the same value as we emit in the GrantDonation method. Sorry, I don't think I understand the issue you are pointing out 😬

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@corydickson I'm just running the example in my head and i don't see an issue either.
When donate is invoked for a given token -> sum of ratios should be 1e18.

So if we have a token with 16 decimals and contributions are made to 3 diff grants -> 5 , 5, 10
The ratio would be 25%, 25%, 50%.
The frontend would backfill the last 2 decimals with 0 and send it.

So once we do the swap to 18 decimal token -> you'd still have the same ratio of 25%, 25%, 50% right ?

The only scenario where this might mess things up is if we had a token > 18 decimals (which is not something we need to be bothered about)

GrantRound[] rounds; // rounds against which the donation should be counted
}

// --- Events ---
/// @notice Emitted when a new GrantRound contract is created
event GrantRoundCreated(address grantRound);

/// @notice Emitted when a donation has been made
event GrantDonation(
uint96 indexed grantId,
IERC20 indexed tokenIn,
uint256 amountIn,
uint256 amountOut,
GrantRound[] rounds
);
event GrantDonation(uint96 indexed grantId, IERC20 indexed tokenIn, uint256 donationAmount, GrantRound[] rounds);

// --- Constructor ---
constructor(
GrantRegistry _registry,
ISwapRouter _router,
IERC20 _donationToken
) {
// Validation
require(_registry.grantCount() >= 0, "GrantRoundManager: Invalid registry");
require(address(_router).isContract(), "GrantRoundManager: Invalid router"); // Router interface doesn't have a state variable to check
require(_donationToken.totalSupply() > 0, "GrantRoundManager: Invalid token");

// Set state
registry = _registry;
router = _router;
donationToken = _donationToken;

// Token approvals of common tokens
// TODO inherit from SwapRouter to remove the need for this approvals and extra safeTransferFrom before swap
IERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F).safeApprove(address(_router), type(uint256).max); // DAI
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just want to make sure I understand: as currently written, the donate method will fail if it's not using one of these pre-approved tokens, and there is no mechanism for the contract to approve new ones. Correct?

Inheriting from SwapRouter means we'd become a custom v3 router, and users would have to trust (or verify by inspected verified source code) that we did not modify the router functionality in some malicious or unsafe way. Correct?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct on all counts! If for whatever reason we don't end up inheriting from SwapRouter, I think we'd want a public method where you pass in a token address and it approves the SwapRouter to spend MaxUint of that token. Otherwise we have to check allowances every donation which would get very costly

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, sounds good. I think we want to go this route (ha!). Created this issue to track as a separate PR: #99

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a clarification -> Even if we inherited the list -> wouldn't we still have to do an approve for every token on the list right ? (the only diff is we won't be hardcoding every token within this contract )

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So here is the flow when you normally use Uniswap as a user:

  1. Approve router to spend your tokens
  2. Call swap on router, which takes your tokens and executes swap
  3. (1 total approval, 1 transferFrom)

Here is our current flow:

  1. Approve manager to spend your tokens
  2. Manager approves router to spend it's tokens
  3. Call donate on manager, which takes your tokens, the router takes tokens from manager to execute swap (no more approvals)
  4. (2 total approvals, 2 transferFroms)

So once we inherit from SwapRouter, we'll migrate to the top flow, which will reduce approvals and transfers

IERC20(0xDe30da39c46104798bB5aA3fe8B9e0e1F348163F).safeApprove(address(_router), type(uint256).max); // GTC
IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48).safeApprove(address(_router), type(uint256).max); // USDC
IERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7).safeApprove(address(_router), type(uint256).max); // USDT
IERC20(0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599).safeApprove(address(_router), type(uint256).max); // WBTC
WETH.safeApprove(address(_router), type(uint256).max); // WETH
}

// --- Core methods ---

/**
* @notice Creates a new GrantRound
* @param _owner Grant round owner that has permission to update the metadata pointer
Expand Down Expand Up @@ -99,66 +126,119 @@ contract GrantRoundManager {
}

/**
* @notice Swap and donate to a grant
* @param _donation Donation being made to a grant
* @notice Performs swaps if necessary and donates funds as specified
* @param _swaps Array of SwapSummary objects describing the swaps required
* @param _deadline Unix timestamp after which a swap will revert, i.e. swap must be executed before this
* @param _donations Array of donations to execute
* @dev `_deadline` is not part of the `_swaps` array since all swaps can use the same `_deadline` to save some gas
* @dev Caller must ensure the input tokens to the _swaps array are unique
* @dev Does not verify
*/
function swapAndDonate(Donation calldata _donation) external payable {
// --- Validation ---
// Rounds must be specified
GrantRound[] calldata _rounds = _donation.rounds;
require(_rounds.length > 0, "GrantRoundManager: Must specify at least one round");

// Only allow value to be sent if the input token is WETH (this limitation should be fixed in #76, as this
// require statement prohibits donating WETH)
IERC20 _tokenIn = IERC20(_donation.path.toAddress(0));
require(
(msg.value == 0 && address(_tokenIn) != WETH) || (msg.value > 0 && address(_tokenIn) == WETH),
"GrantRoundManager: Invalid token-value pairing"
);
function donate(
SwapSummary[] calldata _swaps,
uint256 _deadline,
Donation[] calldata _donations
) external payable {
// Main logic
_validateDonations(_donations);
_executeDonationSwaps(_swaps, _deadline);
_transferDonations(_donations);

// Clear storage for refunds (this is set in _executeDonationSwaps)
for (uint256 i = 0; i < _swaps.length; i++) {
IERC20 _tokenIn = IERC20(_swaps[i].path.toAddress(0));
swapOutputs[_tokenIn] = 0;
donationRatios[_tokenIn] = 0;
}
}

// Ensure grant recieving donation exists in registry
uint96 _grantId = _donation.grantId;
require(_grantId < registry.grantCount(), "GrantRoundManager: Grant does not exist in registry");

// Iterate through each GrantRound to verify:
// - The round has the same donationToken as the GrantRoundManager
// - The round is active
for (uint256 i = 0; i < _rounds.length; i++) {
require(_rounds[i].isActive(), "GrantRoundManager: GrantRound is not active");
require(
donationToken == _rounds[i].donationToken(),
"GrantRoundManager: GrantRound's donation token does not match GrantRoundManager's donation token"
);
/**
* @dev Validates the the inputs to a donation call are valid, and reverts if any requirements are violated
* @param _donations Array of donations that will be executed
*/
function _validateDonations(Donation[] calldata _donations) internal {
// TODO consider moving this to the section where we already loop through donations in case that saves a lot of
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are we saying

  • keep the function
  • let the argument be Donation _donation
  • it gets invoked as the first thing in _transferDonations

It does seem a smart way to save gas but this would mean -> we'd do it after the swap happens which is I am not very comfortable about (but the argument could be made -> if you invoke it wrong -> it's not the contract's fault )

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea pretty much! The benefits of the current approach are that you save gas in the event of a failure (by reverting before the swap), but downside is more gas overall since you repeat a loop. I don't think the extra gas is significant, so I think we should we leave this as-is for readability, and come back to it later when we focus on optimizing gas usage

// gas. Leaving it here for now to improve readability

for (uint256 i = 0; i < _donations.length; i++) {
// Validate grant exists
require(_donations[i].grantId < registry.grantCount(), "GrantRoundManager: Grant does not exist in registry");

// Used later to validate ratios are correctly provided
donationRatios[_donations[i].token] += _donations[i].ratio;

// Validate round parameters
GrantRound[] calldata _rounds = _donations[i].rounds;
for (uint256 j = 0; j < _rounds.length; j++) {
require(_rounds[j].isActive(), "GrantRoundManager: GrantRound is not active");
require(
donationToken == _rounds[j].donationToken(),
"GrantRoundManager: GrantRound's donation token does not match GrantRoundManager's donation token"
);
}
}
}

// --- Swap ---
address _payoutAddress = registry.getGrantPayee(_grantId);
uint256 _amountIn = _donation.amountIn;
uint256 _amountOut = _amountIn; // by default, by may be overwritten in the swap branch below
/**
* @dev Performs swaps if necessary
* @param _swaps Array of SwapSummary objects describing the swaps required
* @param _deadline Unix timestamp after which a swap will revert, i.e. swap must be executed before this
*/
function _executeDonationSwaps(SwapSummary[] calldata _swaps, uint256 _deadline) internal {
for (uint256 i = 0; i < _swaps.length; i++) {
// Validate ratios sum to 100%
IERC20 _tokenIn = IERC20(_swaps[i].path.toAddress(0));
require(donationRatios[_tokenIn] == WAD, "GrantRoundManager: Ratios do not sum to 100%");
require(swapOutputs[_tokenIn] == 0, "GrantRoundManager: Swap parameter has duplicate input tokens");

// Do nothing if the swap input token equals donationToken
if (_tokenIn == donationToken) {
swapOutputs[_tokenIn] = _swaps[i].amountIn;
continue;
}

// Transfer input token to this contract if required
// TODO inherit from SwapRouter to remove the need for this
if (_tokenIn != WETH || msg.value == 0) {
apbendi marked this conversation as resolved.
Show resolved Hide resolved
_tokenIn.safeTransferFrom(msg.sender, address(this), _swaps[i].amountIn);
}

if (_tokenIn == donationToken && msg.value == 0) {
// ETH as the donation token is not supported, so ensure msg.value is zero
_tokenIn.safeTransferFrom(msg.sender, _payoutAddress, _amountOut); // transfer funds directly to payout address
} else {
// Swap setup
// Otherwise, execute swap
ISwapRouter.ExactInputParams memory params = ISwapRouter.ExactInputParams(
_donation.path,
_payoutAddress, // recipient
_donation.deadline,
_amountIn,
_donation.amountOutMinimum
_swaps[i].path,
address(this), // send output to the contract and it will be transferred later
_deadline,
_swaps[i].amountIn,
_swaps[i].amountOutMin
);
uint256 _value = _tokenIn == WETH && msg.value > 0 ? msg.value : 0;
swapOutputs[_tokenIn] = router.exactInput{value: _value}(params); // save off output amount for later
}
}

// If user is sending a token, transfer it to this contract and approve the router to spend it
if (msg.value == 0) {
_tokenIn.safeTransferFrom(msg.sender, address(this), _amountIn);
_tokenIn.approve(address(router), type(uint256).max); // TODO optimize so we don't call this every time
/**
* @dev Core donation logic that transfers funds to grants
* @param _donations Array of donations to execute
*/
function _transferDonations(Donation[] calldata _donations) internal {
for (uint256 i = 0; i < _donations.length; i++) {
// Get data for this donation
GrantRound[] calldata _rounds = _donations[i].rounds;
uint96 _grantId = _donations[i].grantId;
IERC20 _tokenIn = _donations[i].token;
uint256 _donationAmount = (swapOutputs[_tokenIn] * _donations[i].ratio) / WAD;
require(_donationAmount > 0, "GrantRoundManager: Donation amount must be greater than zero"); // verifies that swap and donation inputs are consistent

// Execute transfer
apbendi marked this conversation as resolved.
Show resolved Hide resolved
address _payee = registry.getGrantPayee(_grantId);
if (_tokenIn == donationToken) {
_tokenIn.safeTransferFrom(msg.sender, _payee, _donationAmount); // transfer token directly from caller
} else {
donationToken.transfer(_payee, _donationAmount); // transfer swap output
}

// Execute swap -- output of swap is sent to the payoutAddress
_amountOut = router.exactInput{value: msg.value}(params);
// Emit event
emit GrantDonation(_grantId, _tokenIn, _donationAmount, _rounds);
}

emit GrantDonation(_grantId, _tokenIn, _amountIn, _amountOut, _rounds);
}
}
5 changes: 3 additions & 2 deletions contracts/contracts/test/MockToken.sol
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity ^0.8.6;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
Expand All @@ -7,7 +8,7 @@ contract MockToken is ERC20 {
_mint(msg.sender, initialSupply);
}

function mint(uint256 initialSupply) external {
_mint(msg.sender, initialSupply);
function mint(uint256 amount) external {
_mint(msg.sender, amount);
}
}
Loading