From 150f90772a580382ade9b2d5c45c4b7e961a0b21 Mon Sep 17 00:00:00 2001 From: ginesdt Date: Thu, 4 Apr 2024 09:00:41 +0200 Subject: [PATCH] Allow filling matching pool with ERC20 tokens --- contracts/streamtide.sol | 152 ++++++++-------- resources/scss/components/form.scss | 2 +- resources/scss/components/popUpGrant.scss | 4 +- resources/scss/components/user.scss | 3 - resources/scss/layouts/admin.scss | 12 +- resources/scss/layouts/leaderboards.scss | 17 +- resources/scss/layouts/profileEdit.scss | 6 +- resources/scss/layouts/round.scss | 112 +++++++++--- src/streamtide/server/business_logic.cljs | 4 + src/streamtide/server/constants.cljs | 1 + src/streamtide/server/db.cljs | 142 ++++++++++++--- .../server/graphql/graphql_resolvers.cljs | 24 ++- .../server/notifiers/notifiers.cljs | 2 +- src/streamtide/server/syncer.cljs | 66 +++++-- src/streamtide/shared/graphql_schema.cljs | 30 +++- src/streamtide/shared/utils.cljs | 26 ++- src/streamtide/ui/admin/round/events.cljs | 136 +++++++++++++-- src/streamtide/ui/admin/round/page.cljs | 162 ++++++++++++++---- src/streamtide/ui/admin/round/subs.cljs | 5 + src/streamtide/ui/admin/rounds/page.cljs | 26 +-- .../ui/components/error_notification.cljs | 3 +- src/streamtide/ui/leaderboard/page.cljs | 21 ++- src/streamtide/ui/send_support/page.cljs | 4 +- src/streamtide/ui/utils.cljs | 3 + test/tests/contract/syncer_test.cljs | 35 ++-- 25 files changed, 768 insertions(+), 230 deletions(-) diff --git a/contracts/streamtide.sol b/contracts/streamtide.sol index 787d90a..6f456b0 100644 --- a/contracts/streamtide.sol +++ b/contracts/streamtide.sol @@ -5,11 +5,11 @@ pragma solidity ^0.8.0; import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import "@openzeppelin/contracts/utils/Context.sol"; - +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; contract MVPCLR is OwnableUpgradeable { - + event AdminAdded(address _admin); event AdminRemoved(address _admin); event BlacklistedAdded(address _blacklisted); @@ -21,9 +21,10 @@ contract MVPCLR is OwnableUpgradeable { event RoundStarted(uint256 roundStart, uint256 roundId, uint256 roundDuration); event RoundClosed(uint256 roundId); // Added event event MatchingPoolDonation(address sender, uint256 value, uint256 roundId); - event Distribute(address to, uint256 amount, uint256 roundId); - event DistributeRound(uint256 roundId, uint256 amount); - + event MatchingPoolDonationToken(address sender, uint256 value, uint256 roundId, address token); + event Distribute(address to, uint256 amount, uint256 roundId, address token); + event DistributeRound(uint256 roundId, uint256 amount, address token); + event Donate( address sender, @@ -48,35 +49,43 @@ contract MVPCLR is OwnableUpgradeable { mapping(address => bool) public isAdmin; mapping(address => bool) public isPatron; mapping(address => bool) public isBlacklisted; - + address public multisigAddress; function construct(address _multisigAddress) external initializer { - __Ownable_init(); // Add this line to initialize the OwnableUpgradeable contract - multisigAddress = _multisigAddress; - roundId = 0; - lastActiveRoundId = 0; -} + __Ownable_init(); // Add this line to initialize the OwnableUpgradeable contract + multisigAddress = _multisigAddress; + roundId = 0; + lastActiveRoundId = 0; + } function setMultisigAddress(address _multisigAddress) external onlyMultisig { - multisigAddress = _multisigAddress; -} + multisigAddress = _multisigAddress; + } function fillUpMatchingPool() public payable onlyAdmin { - require(msg.value > 0, "MVPCLR:fillUpMatchingPool - No value provided"); - emit MatchingPoolDonation(msg.sender, msg.value, roundId); -} + require(msg.value > 0, "MVPCLR:fillUpMatchingPool - No value provided"); + emit MatchingPoolDonation(msg.sender, msg.value, roundId); + } + function fillUpMatchingPoolToken(address from, address token, uint amount) public onlyAdmin { + require(!roundIsClosed()); + require(amount > 0, "MVPCLR:fillUpMatchingPoolToken - No amount provided"); + + IERC20(token).transferFrom(from, address(this), amount); + + emit MatchingPoolDonationToken(from, amount, roundId, token); + } function closeRound() public onlyAdmin { roundDuration = 0; // roundId = 0; emit RoundClosed(lastActiveRoundId); // Added event emission -} + } function roundIsClosed() public view returns (bool) { return roundDuration == 0 || roundStart + roundDuration <= getBlockTimestamp(); -} + } function startRound(uint256 _roundDuration) public payable onlyAdmin { require(roundIsClosed(), "MVPCLR: startRound - Previous round not yet closed"); @@ -88,34 +97,34 @@ contract MVPCLR is OwnableUpgradeable { emit RoundStarted(roundStart, roundId, roundDuration); emit MatchingPoolDonation(msg.sender, msg.value, roundId); // Emit event for the added funds -} + } function addAdmin(address _admin) public onlyOwner { isAdmin[_admin] = true; emit AdminAdded(_admin); -} + } function removeAdmin(address _admin) public onlyOwner { require(isAdmin[_admin], "Admin not found"); // check if the address is an admin delete isAdmin[_admin]; emit AdminRemoved(_admin); -} + } function getBlockTimestamp() public view returns (uint256) { return block.timestamp; -} + } function addBlacklisted(address _address) public onlyAdmin { isBlacklisted[_address] = true; emit BlacklistedAdded(_address); -} + } function removeBlacklisted(address _address) public onlyAdmin { require(isBlacklisted[_address], "Address not blacklisted"); delete isBlacklisted[_address]; emit BlacklistedRemoved(_address); -} + } function addPatrons(address payable[] calldata addresses) public onlyAdmin { for (uint256 i = 0; i < addresses.length; i++) { @@ -124,67 +133,68 @@ contract MVPCLR is OwnableUpgradeable { isPatron[addr] = true; } emit PatronsAdded(addresses); -} + } function donate(address[] memory patronAddresses, uint256[] memory amounts) public payable { - require(patronAddresses.length == amounts.length, "CLR:donate - Mismatch between number of patrons and amounts"); - uint256 totalAmount = 0; - uint256 donationRoundId = roundIsClosed() ? 0 : roundId; - for (uint256 i = 0; i < patronAddresses.length; i++) { - address patronAddress = patronAddresses[i]; - uint256 amount = amounts[i]; - totalAmount += amount; - require(!isBlacklisted[_msgSender()], "Sender address is blacklisted"); - require(isPatron[patronAddress], "CLR:donate - Not a valid recipient"); - emit Donate(_msgSender(), amount, patronAddress, donationRoundId); - bool success = payable(patronAddress).send(amount); - require(success, "CLR:donate - Failed to send funds to recipient"); - } - - require(totalAmount <= msg.value, "CLR:donate - Total amount donated is greater than the value sent"); - // transfer the donated funds to the contract - // payable(address(this)).transfer(msg.value); -} + require(patronAddresses.length == amounts.length, "CLR:donate - Mismatch between number of patrons and amounts"); + uint256 totalAmount = 0; + uint256 donationRoundId = roundIsClosed() ? 0 : roundId; + for (uint256 i = 0; i < patronAddresses.length; i++) { + address patronAddress = patronAddresses[i]; + uint256 amount = amounts[i]; + totalAmount += amount; + require(!isBlacklisted[_msgSender()], "Sender address is blacklisted"); + require(isPatron[patronAddress], "CLR:donate - Not a valid recipient"); + emit Donate(_msgSender(), amount, patronAddress, donationRoundId); + bool success = payable(patronAddress).send(amount); + require(success, "CLR:donate - Failed to send funds to recipient"); + } + require(totalAmount <= msg.value, "CLR:donate - Total amount donated is greater than the value sent"); + // transfer the donated funds to the contract + // payable(address(this)).transfer(msg.value); + } - function distribute(address payable[] memory patrons, uint[] memory amounts) public onlyAdmin { - require(patrons.length == amounts.length, "Length of patrons and amounts must be the same"); - uint256 totalAmount = 0; // Store total amount to be distributed - // Loop through the list of patrons and distribute the funds to each address - for (uint i = 0; i < patrons.length; i++) { - // Make sure the recipient address is a valid patron address - require(isPatron[patrons[i]], "CLR:distribute - Not a valid recipient"); - patrons[i].transfer(amounts[i]); // Reverts transaction if transfer fails - emit Distribute(patrons[i], amounts[i], roundId); - totalAmount += amounts[i]; // Add the amount to totalAmount - } + function distribute(address payable[] memory patrons, uint[] memory amounts, address token) public onlyAdmin { + require(patrons.length == amounts.length, "Length of patrons and amounts must be the same"); + uint256 totalAmount = 0; // Store total amount to be distributed + + // Loop through the list of patrons and distribute the funds to each address + for (uint i = 0; i < patrons.length; i++) { + // Make sure the recipient address is a valid patron address + require(isPatron[patrons[i]], "CLR:distribute - Not a valid recipient"); + if (token == address(0)) + patrons[i].transfer(amounts[i]); // Reverts transaction if transfer fails + else + IERC20(token).transfer(patrons[i], amounts[i]); + emit Distribute(patrons[i], amounts[i], roundId, token); + totalAmount += amounts[i]; // Add the amount to totalAmount + } // matchingPool -= totalAmount; // Subtract the total distributed amount from the matching pool - emit DistributeRound(roundId, totalAmount); -} - + emit DistributeRound(roundId, totalAmount, token); + } + //only designated multisig address can call this function function withdrawFunds(uint256 amount) external onlyMultisig { - require(address(this).balance >= amount, "Insufficient funds in contract"); - payable(multisigAddress).transfer(amount); -} - - + require(address(this).balance >= amount, "Insufficient funds in contract"); + payable(multisigAddress).transfer(amount); + } // receive donation for the matching pool receive() external payable { - require(roundStart == 0 || getBlockTimestamp() < roundStart + roundDuration,"CLR:receive closed"); - emit MatchingPoolDonation(_msgSender(), msg.value, roundId); -} + require(roundStart == 0 || getBlockTimestamp() < roundStart + roundDuration, "CLR:receive closed"); + emit MatchingPoolDonation(_msgSender(), msg.value, roundId); + } modifier onlyAdmin() { - require(isAdmin[msg.sender] == true, "Not an admin"); - _; -} + require(isAdmin[msg.sender] == true, "Not an admin"); + _; + } modifier onlyMultisig() { - require(msg.sender == multisigAddress, "Not authorized"); - _; -} + require(msg.sender == multisigAddress, "Not authorized"); + _; + } } diff --git a/resources/scss/components/form.scss b/resources/scss/components/form.scss index a9a26e1..6fe3a8f 100644 --- a/resources/scss/components/form.scss +++ b/resources/scss/components/form.scss @@ -73,7 +73,7 @@ } } .select__menu { - border: 3px solid #a478f9; + border: 3px solid $lilac; border-radius: 7px; background-color: $bgSite; } diff --git a/resources/scss/components/popUpGrant.scss b/resources/scss/components/popUpGrant.scss index 5676b54..f69cce6 100644 --- a/resources/scss/components/popUpGrant.scss +++ b/resources/scss/components/popUpGrant.scss @@ -180,7 +180,7 @@ width: calc(100% - 6px); border-radius: 7px; overflow: hidden; - box-shadow: 0px 0px 0px 3px #a478f9; + box-shadow: 0px 0px 0px 3px $lilac; height: 277px; margin-bottom: 11px; text-align: left; @@ -281,7 +281,7 @@ a { text-decoration: underline; text-decoration-thickness: 2px; - color: #a478f9; + color: $lilac; &:hover { color: #3b1d56; } diff --git a/resources/scss/components/user.scss b/resources/scss/components/user.scss index 105616a..b0d426a 100644 --- a/resources/scss/components/user.scss +++ b/resources/scss/components/user.scss @@ -33,7 +33,6 @@ position: absolute; bottom: 0; right: -9.5px; - z-index: 10; } } &.lb { @@ -71,7 +70,6 @@ position: absolute; top: 6px; right: 6px; - z-index: 10; } } } @@ -87,7 +85,6 @@ position: absolute; top: -2px; right: -3px; - z-index: 10; } } @include media-breakpoint-up(md) { diff --git a/resources/scss/layouts/admin.scss b/resources/scss/layouts/admin.scss index 66391d0..8597260 100644 --- a/resources/scss/layouts/admin.scss +++ b/resources/scss/layouts/admin.scss @@ -62,7 +62,7 @@ bottom: 5px; right: 5px; font-size: smaller; - background: #a478f9; + background: $lilac; border-radius: 92px; padding: 1px 4px; color: white; @@ -284,7 +284,7 @@ width: calc(100% - 6px); border-radius: 7px; overflow: hidden; - box-shadow: 0px 0px 0px 3px #a478f9; + box-shadow: 0px 0px 0px 3px $lilac; height: 36px; margin: 0 auto 21px; .input-group { @@ -421,6 +421,11 @@ .cel { display: flex; justify-content: space-between; + + .amounts { + display: flex; + flex-direction: column; + } } margin-bottom: 14px; background: $bgCard; @@ -636,6 +641,9 @@ justify-content: space-between; .cel { width: calc(33.33% - 8.66px); + .amounts { + row-gap: 5px; + } } .data { margin-bottom: 0; diff --git a/resources/scss/layouts/leaderboards.scss b/resources/scss/layouts/leaderboards.scss index 5cc1a43..052ed13 100644 --- a/resources/scss/layouts/leaderboards.scss +++ b/resources/scss/layouts/leaderboards.scss @@ -17,6 +17,9 @@ margin-right: 21.05px; } .data { + a { + width: 100%; + } width: calc(100% - 80px); font-size: 16px; line-height: 22px; @@ -31,10 +34,10 @@ li { width: 100%; display: flex; - align-items: center; justify-content: space-between; font-size: 16px; - line-height: 28px; + line-height: 22px; + margin-bottom: 8px; h4 { font-weight: $bold; color: $blackWhite; @@ -42,6 +45,11 @@ &:last-child { margin-bottom: 0; } + + .amounts { + display: flex; + flex-direction: column; + } } } } @@ -84,7 +92,7 @@ margin-bottom: 11.62px; display: flex; flex-wrap: nowrap; - padding: 13.36px 13.83px 18.83px; + padding: 13.83px 13.83px 13.83px; .user { width: 56px; margin-right: 23.66px; @@ -115,6 +123,7 @@ width: 33.33%; display: block; text-align: center; + margin-bottom: 0px; } } } @@ -160,7 +169,7 @@ margin-bottom: 11.62px; display: flex; flex-wrap: nowrap; - padding: 13.36px 13.83px 18.83px; + padding: 13.83px 13.83px 13.83px; .user { width: 56px; margin-right: 23.66px; diff --git a/resources/scss/layouts/profileEdit.scss b/resources/scss/layouts/profileEdit.scss index e84e125..dea6ab4 100644 --- a/resources/scss/layouts/profileEdit.scss +++ b/resources/scss/layouts/profileEdit.scss @@ -132,7 +132,7 @@ width: calc(100% - 6px); border-radius: 7px; overflow: hidden; - box-shadow: 0px 0px 0px 3px #a478f9; + box-shadow: 0px 0px 0px 3px $lilac; .input-group { height: 100%; } @@ -231,7 +231,7 @@ margin-bottom: 5px; } .collapsible-header { - background-color: #a478f9; + background-color: $lilac; height: 52.77px; width: 100%; display: flex; @@ -247,7 +247,7 @@ .collapsible-content { border-bottom-left-radius: 7px; border-bottom-right-radius: 7px; - border: 3px solid #a478f9; + border: 3px solid $lilac; padding: 0 15.75px; } .categories { diff --git a/resources/scss/layouts/round.scss b/resources/scss/layouts/round.scss index f29cb81..eb0653e 100644 --- a/resources/scss/layouts/round.scss +++ b/resources/scss/layouts/round.scss @@ -3,26 +3,39 @@ .fillPoolForm { display: flex; - justify-content: space-between; + column-gap: 5px; + flex-wrap: wrap; margin-top: 15px; + margin-bottom: 15px; .inputField { + > span { + white-space: nowrap; + } + span { font-size: 18px; margin-right: 18px; letter-spacing: normal; } - .help-block { - display: none; - } + width: 100%; - width: calc(82% - 10px); + &.coin { + width: calc(100% - 165px); + max-width: 580px; + input { + font-size: 16px; + } + } } - - .input-group { - width: unset; + .btValidateCoin { + height: 56px; + &:disabled { + pointer-events: none; + background-color: $bgDisabled; + } } label.inputField { @@ -42,28 +55,56 @@ } } - .btBasic { - width: calc(18% - 10px); + .custom-select.selectForm { + width: 160px; + .select__value-container { + font-weight: bold; + } + } + + .coin-symbol { height: 56px; + display: inline-flex; + align-items: center; + border: 3px solid $lilac; + padding: 10px; + background: $bgDisabled; + border-radius: 7px; + font-weight: bold; + } + } + .btBasic { + height: 56px; + &:disabled { + pointer-events: none; + background-color: $bgDisabled; + } + } + .buttons { + display: flex; + justify-content: space-between; + + .btBasic { + width: calc(18% - 10px); + } + .btMatchPool, .btCloseRound { + min-width: 210px; + } + .btCloseRound { + background: chocolate; &:disabled { pointer-events: none; - background-color: $bgDisabled; + background-color: darksalmon; } } } - .closeRoundForm { + .matching, .distributed { display: flex; - justify-content: right; - } - .btMatchPool, .btCloseRound { - min-width: 210px; - } - .btCloseRound { - background: chocolate; - &:disabled { - pointer-events: none; - background-color: darksalmon; + column-gap: 5px; + .amounts { + display: flex; + flex-direction: column; } } } @@ -88,6 +129,33 @@ background-color: $bgDisabled; } } + .coin-container { + display: flex; + align-items: center; + column-gap: 10px; + + .coin { + width: 160px; + margin: 8px 0; + + } + .custom-select.selectForm { + .select__value-container { + font-weight: bold; + } + } + + .inputField { + height: 44px; + line-height: 38px; + padding: 0 17px; + font-size: 13px; + + &.disabled { + background: $disabled; + } + } + } } .donation, .receiver{ width: 100%; diff --git a/src/streamtide/server/business_logic.cljs b/src/streamtide/server/business_logic.cljs index 76c466c..638531c 100644 --- a/src/streamtide/server/business_logic.cljs +++ b/src/streamtide/server/business_logic.cljs @@ -114,6 +114,10 @@ "Gets all the rounds info" (stdb/get-rounds args)) +(defn get-coin [_current-user address] + "Gets coin info from its ETH address" + (stdb/get-coin address)) + (defn verify-social! [current-user {:keys [:state] :as args}] "Verify a social network, for example checking the authentication code coming from user authentication is valid. This returns a channel" diff --git a/src/streamtide/server/constants.cljs b/src/streamtide/server/constants.cljs index 61431d4..39eebfc 100644 --- a/src/streamtide/server/constants.cljs +++ b/src/streamtide/server/constants.cljs @@ -9,6 +9,7 @@ :streamtide/round-started-event [:streamtide-fwd :RoundStarted] :streamtide/round-closed-event [:streamtide-fwd :RoundClosed] :streamtide/matching-pool-donation-event [:streamtide-fwd :MatchingPoolDonation] + :streamtide/matching-pool-donation-token-event [:streamtide-fwd :MatchingPoolDonationToken] :streamtide/distribute-event [:streamtide-fwd :Distribute] :streamtide/distribute-round-event [:streamtide-fwd :DistributeRound] :streamtide/donate-event [:streamtide-fwd :Donate]}) diff --git a/src/streamtide/server/db.cljs b/src/streamtide/server/db.cljs index 16919ff..062572b 100644 --- a/src/streamtide/server/db.cljs +++ b/src/streamtide/server/db.cljs @@ -1,6 +1,8 @@ (ns streamtide.server.db "Module for defining database structure and managing and abstracting queries to the database" - (:require [district.server.config :refer [config]] + (:require [cljs-web3-next.helpers :refer [zero-address]] + [clojure.string :as string] + [district.server.config :refer [config]] [district.server.db :as db] [district.server.db.column-types :refer [address default-nil default-zero default-false not-nil primary-key]] [honeysql-postgres.helpers :as psqlh] @@ -19,12 +21,9 @@ :stop (stop)) ; columns holding big numbers -(def big-numbers-fields [:round/matching-pool - :round/distributed +(def big-numbers-fields [:matching-pool/amount + :matching-pool/distributed :matching/amount - :leader/donation-amount - :leader/matching-amount - :leader/total-amount :donation/amount :user/min-donation]) @@ -127,6 +126,7 @@ [:round/id :unsigned :integer] [(sql/call :foreign-key :donation/sender) (sql/call :references :user :user/address) (sql/raw "ON DELETE CASCADE")] [(sql/call :foreign-key :donation/receiver) (sql/call :references :user :user/address) (sql/raw "ON DELETE CASCADE")] + [(sql/call :foreign-key :donation/coin) (sql/call :references :coin :coin/address)] [(sql/call :foreign-key :round/id) (sql/call :references :round :round/id)]]) (def matching-columns @@ -137,6 +137,7 @@ [:matching/coin address not-nil] [:round/id :unsigned :integer] [(sql/call :foreign-key :matching/receiver) (sql/call :references :user :user/address) (sql/raw "ON DELETE CASCADE")] + [(sql/call :foreign-key :matching/coin) (sql/call :references :coin :coin/address)] [(sql/call :foreign-key :round/id) (sql/call :references :round :round/id)]]) (def user-roles-columns @@ -166,9 +167,22 @@ (def round-columns [[:round/id :integer primary-key not-nil] [:round/start :timestamp not-nil] - [:round/duration :unsigned :integer not-nil] - [:round/matching-pool :unsigned :integer not-nil] ;; TODO use string to avoid precision errors? order-by is important - [:round/distributed :unsigned :integer]]) + [:round/duration :unsigned :integer not-nil]]) + +(def matching-pool-columns + [[:round/id :unsigned :integer] + [:matching-pool/coin :address not-nil] + [:matching-pool/amount :unsigned :integer] ;; TODO use string to avoid precision errors? order-by is important + [:matching-pool/distributed :unsigned :integer] + [(sql/call :primary-key :round/id :matching-pool/coin)] + [(sql/call :foreign-key :matching-pool/coin) (sql/call :references :coin :coin/address)] + [(sql/call :foreign-key :round/id) (sql/call :references :round :round/id)]]) + +(def coin-columns + [[:coin/address address primary-key not-nil] + [:coin/name :varchar default-nil] + [:coin/symbol :varchar default-nil] + [:coin/decimals :unsigned :integer default-nil]]) (def events-columns [[:event/contract-key :varchar not-nil] @@ -194,6 +208,8 @@ (def user-content-permission-column-names (filter keyword? (map first user-content-permission-columns))) (def announcement-column-names (filter keyword? (map first announcement-columns))) (def round-column-names (filter keyword? (map first round-columns))) +(def matching-pool-column-names (filter keyword? (map first matching-pool-columns))) +(def coin-column-names (filter keyword? (map first coin-columns))) (def events-column-names (filter keyword? (map first events-columns))) @@ -390,20 +406,42 @@ (or (keyword order-dir) :asc)]]))] (paged-query query page-size page-start-idx))) +(defn group-leaders [leaders] + (vals (reduce + (fn [ret x] + (let [k (:user/address x)] + (assoc ret k (merge (select-keys x (merge user-column-names :leader/donation-amount)) + (let [ma (get-in ret [k :leader/matching-amounts] []) + ta (get-in ret [k :leader/total-amounts] [])] + {:leader/matching-amounts (if (:matching/coin x) + (conj ma (clojure.set/rename-keys (select-keys x [:matching/coin :leader/matching-amount]) + {:matching/coin :coin :leader/matching-amount :amount})) + ma) + :leader/total-amounts (if (:matching/coin x) + (conj ta (clojure.set/rename-keys (select-keys x [:matching/coin :leader/total-amount]) + {:matching/coin :coin :leader/total-amount :amount})) + ta)}))))) + {} leaders))) + (defn get-leaders [{:keys [:round :search-term :order-by :order-dir :first :after] :as args}] (let [page-start-idx (when after (js/parseInt after)) page-size first sub-query-donations (cond-> {:select [:donation/receiver [(sql/call :sum :donation/amount) :donations]] :from [:donation] :group-by [:donation/receiver]} round (sqlh/merge-where [:= :donation.round/id round])) - sub-query-matchings (cond-> {:select [:matching/receiver [(sql/call :sum :matching/amount) :matchings]] - :from [:matching] :group-by [:matching/receiver]} + sub-query-matchings (cond-> {:select [:matching/receiver :matching/coin [(sql/call :sum :matching/amount) :matchings]] + :from [:matching] :group-by [:matching/receiver :matching/coin]} round (sqlh/merge-where [:= :matching.round/id round])) query (cond-> {:select [:u.* + :m.matching/coin [(sql/call :coalesce :donations 0) :leader/donation-amount] [(sql/call :coalesce :matchings 0) :leader/matching-amount] - [(sql/call :+ (sql/call :coalesce :donations 0) (sql/call :coalesce :matchings 0)) :leader/total-amount]] + [(sql/call :case + (sql/call := :matching/coin zero-address) + (sql/call :+ (sql/call :coalesce :donations 0) (sql/call :coalesce :matchings 0)) + :else (sql/call :coalesce :matchings 0)) + :leader/total-amount]] :from [[:user :u]] :left-join [[sub-query-donations :d] [:= :d.donation/receiver :u.user/address] @@ -417,26 +455,60 @@ :leaders.order-by/matching-amount :leader/matching-amount :leaders.order-by/total-amount :leader/total-amount} order-by) - (or (keyword order-dir) :asc)]]))] - (paged-query query page-size page-start-idx))) + (or (keyword order-dir) :asc)]])) + leaders (paged-query query page-size page-start-idx)] + (update leaders :items group-leaders))) + +(defn group-rounds [rounds] + (vals (reduce + (fn [ret x] + (let [k (:round/id x)] + (assoc ret k (merge + (get ret k) + (select-keys x [:round/id :round/start :round/duration]) + (let [mp (get-in ret [k :round/matching-pools] [])] + {:round/matching-pools (if (:matching-pool/coin x) + (conj mp (select-keys x [:matching-pool/coin :matching-pool/amount :matching-pool/distributed])) + mp)}))))) + {} rounds))) (defn get-round [round-id] - (db-get {:select [:*] - :from [:round] - :where [:= round-id :round.round/id]})) + (let [rounds (db-all {:select [:r.* :mp.matching-pool/coin + [(sql/call :coalesce :mp.matching-pool/amount 0) :matching-pool/amount] + [(sql/call :coalesce :mp.matching-pool/distributed 0) :matching-pool/distributed]] + :from [[:round :r]] + :left-join [[:matching-pool :mp] [:= :r.round/id :mp.round/id]] + :where [:= round-id :r.round/id]})] + (first (group-rounds rounds)))) (defn get-rounds [{:keys [:order-by :order-dir :first :after] :as args}] (let [page-start-idx (when after (js/parseInt after)) page-size first - query (cond-> - {:select [:r.*] + round-query (cond-> + {:select [:*] :from [[:round :r]]} order-by (sqlh/merge-order-by [[(get {:rounds.order-by/date :r.round/start - :rounds.order-by/matching-pool :r.round/matching-pool :rounds.order-by/id :r.round/id} order-by) - (or (keyword order-dir) :asc)]]))] - (paged-query query page-size page-start-idx))) + (or (keyword order-dir) :asc)]])) + rounds (paged-query round-query page-size page-start-idx) + mp-query {:select [:*] + :from [[:matching-pool :mp]] + :where [:in :mp.round/id (map #(:round/id %) (:items rounds))]} + mps (db-all mp-query)] + (update rounds :items #(group-rounds (apply merge % mps))))) + +(defn get-matching-pool [round-id coin-address] + (db-get {:select [:*] + :from [[:matching-pool :mp]] + :where [:and + [:= round-id :mp.round/id] + [:= coin-address :mp.matching-pool/coin]]})) + +(defn get-coin [address] + (db-get {:select [:*] + :from [:coin] + :where [:= (string/lower-case address) :coin.coin/address]})) (defn get-user-timestamps [{:keys [:user/address]}] (db-get {:select [:*] @@ -612,6 +684,21 @@ :set round-info :where [:= :round/id id]}))) +(defn upsert-matching-pool! [{:keys [:round/id :matching-pool/coin] :as args}] + (log/debug "update-matching-pool" args) + (let [matching-pool (select-keys args matching-pool-column-names)] + (db-run! {:insert-into :matching-pool + :values [matching-pool] + :upsert {:on-conflict [:round/id :matching-pool/coin] + :do-update-set (keys matching-pool)}}))) + +(defn add-coin! [args] + (log/debug "add-coin" args) + (db-run! {:insert-into :coin + :values [(update (select-keys args coin-column-names) :coin/address string/lower-case)] + :upsert {:on-conflict [:coin/address] + :do-nothing []}})) + (defn blacklisted? [{:keys [:user/address] :as args}] (let [bl (:user/blacklisted (db-get {:select [:user/blacklisted] :from [:user] @@ -759,6 +846,17 @@ (db-run! (-> (psqlh/create-table :round :if-not-exists) (psqlh/with-columns round-columns))) + (db-run! (-> (psqlh/create-table :matching-pool :if-not-exists) + (psqlh/with-columns matching-pool-columns))) + + (db-run! (-> (psqlh/create-table :coin :if-not-exists) + (psqlh/with-columns coin-columns))) + + (add-coin! {:coin/address zero-address + :coin/decimals 18 + :coin/symbol "ETH" + :coin/name "Ether"}) + (db-run! (-> (psqlh/create-table :events :if-not-exists) (psqlh/with-columns events-columns)))) diff --git a/src/streamtide/server/graphql/graphql_resolvers.cljs b/src/streamtide/server/graphql/graphql_resolvers.cljs index 72bc096..b6acf46 100644 --- a/src/streamtide/server/graphql/graphql_resolvers.cljs +++ b/src/streamtide/server/graphql/graphql_resolvers.cljs @@ -110,6 +110,10 @@ (log/debug "donation->sender-resolver args" user-donation) (logic/get-user (user-id current-user) sender)) +(defn donation->coin-resolver [{:keys [:donation/coin] :as donation-coin} _ {:keys [:current-user]}] + (log/debug "donation->coin-resolver args" donation-coin) + (logic/get-coin (user-id current-user) coin)) + (defn donation->receiver-resolver [{:keys [:donation/receiver] :as user-donation}] (log/debug "donation->receiver-resolver args" user-donation) user-donation) @@ -118,6 +122,18 @@ (log/debug "matching->receiver-resolver args" user-donation) user-donation) +(defn matching->coin-resolver [{:keys [:matching/coin] :as matching-coin} _ {:keys [:current-user]}] + (log/debug "matching->coin-resolver args" matching-coin) + (logic/get-coin (user-id current-user) coin)) + +(defn matching-pool->coin-resolver [{:keys [:matching-pool/coin] :as matching-pool-coin} _ {:keys [:current-user]}] + (log/debug "matching-pool->coin-resolver args" matching-pool-coin) + (logic/get-coin (user-id current-user) coin)) + +(defn coin-amount->coin-resolver [{:keys [:coin] :as coin-amount} _ {:keys [:current-user]}] + (log/debug "coin-amount->coin-resolver args" coin-amount) + (logic/get-coin (user-id current-user) coin)) + (defn leader->receiver-resolver [{:keys [:leader/receiver] :as user-donation}] (log/debug "leader->receiver-resolver args" user-donation) user-donation) @@ -373,8 +389,12 @@ :Content {:content/user content->user-resolver :content/type content->type-resolver} :Donation {:donation/receiver donation->receiver-resolver - :donation/sender donation->sender-resolver} - :Matching {:matching/receiver matching->receiver-resolver} + :donation/sender donation->sender-resolver + :donation/coin donation->coin-resolver} + :Matching {:matching/receiver matching->receiver-resolver + :matching/coin matching->coin-resolver} :Leader {:leader/receiver leader->receiver-resolver} + :MatchingPool {:matching-pool/coin matching-pool->coin-resolver} + :CoinAmount {:coin coin-amount->coin-resolver} }) diff --git a/src/streamtide/server/notifiers/notifiers.cljs b/src/streamtide/server/notifiers/notifiers.cljs index 60763a2..d65bfdf 100644 --- a/src/streamtide/server/notifiers/notifiers.cljs +++ b/src/streamtide/server/notifiers/notifiers.cljs @@ -64,7 +64,7 @@ notification {:title "Donation received" :body (gstring/format "You have received a donation from %s of a value of %s" (if (string/blank? (:user/name sender)) (:donation/sender donation) (:user/name sender)) - (shared-utils/format-price (:donation/amount donation)))} + (shared-utils/format-price (:donation/amount donation) {:coin/decimals 18 :coin/symbol "ETH"}))} notification-entries (stdb/get-notification-categories {:user/address (:donation/receiver donation) :notification/category (name :notification-category/donations) :notification/enable true})] diff --git a/src/streamtide/server/syncer.cljs b/src/streamtide/server/syncer.cljs index 296f8bc..21ec9cf 100644 --- a/src/streamtide/server/syncer.cljs +++ b/src/streamtide/server/syncer.cljs @@ -4,10 +4,12 @@ [bignumber.core :as bn] [camel-snake-kebab.core :as camel-snake-kebab] [cljsjs.bignumber] - [cljs-web3-next.eth :as web3-eth] [cljs-web3-next.core :as web3-core] + [cljs-web3-next.eth :as web3-eth] + [cljs-web3-next.helpers :refer [zero-address]] [cljs.core.async :as async :refer [ (js/BigNumber. value) (js/BigNumber. 0)) + (let [matching-pool (db/get-matching-pool round-id token) + amount (bn/+ (js/BigNumber. (or (:matching-pool/amount matching-pool) 0)) (js/BigNumber. value))] + (db/upsert-matching-pool! + {:round/id round-id + :matching-pool/coin (string/lower-case token) + :matching-pool/amount (bn/fixed amount)})))) + +(defn nullify-err [v] + (if (cljs.core/instance? js/Error v) nil v)) + +(defn fetch-coin-info [coin-address] + (safe-go + (let [contract (web3-eth/contract-at @web3 abi-reduced-erc20 coin-address) + decimals ( (count decimal-part) decimals) (subs decimal-part 0 decimals) decimal-part) + zeros-to-append (- decimals (count decimal-part)) + zeros (apply str (repeat zeros-to-append "0")) + base-amount (str int-part (or decimal-part "") zeros)] + (clojure.string/replace-first base-amount #"^0+" ""))) + +(defn from-base-amount [base-amount decimals] + (clojure.string/replace + (let [base-amount-len (count base-amount)] + (if (< decimals base-amount-len) + (let [int-part (subs base-amount 0 (- base-amount-len decimals)) + decimal-part (subs base-amount (- base-amount-len decimals))] + (str int-part "." decimal-part)) + (str "0." (apply str (repeat (- decimals base-amount-len) "0")) base-amount))) + #"\.?0*$" "")) + +(defn format-price [price {:keys [:coin/symbol :coin/decimals]}] + (let [price (from-base-amount price decimals) min-fraction-digits (if (= "0" price) 0 4)] (format/format-token (bn/number price) {:max-fraction-digits 5 - :token "ETH" + :token symbol :min-fraction-digits min-fraction-digits}))) (def auth-data-msg diff --git a/src/streamtide/ui/admin/round/events.cljs b/src/streamtide/ui/admin/round/events.cljs index ba37e22..995384d 100644 --- a/src/streamtide/ui/admin/round/events.cljs +++ b/src/streamtide/ui/admin/round/events.cljs @@ -1,14 +1,17 @@ (ns streamtide.ui.admin.round.events (:require [cljs-web3-next.core :as web3] + [cljs-web3-next.eth :as web3-eth] [district.ui.logging.events :as logging] [district.ui.notification.events :as notification-events] [district.ui.smart-contracts.queries :as contract-queries] [district.ui.web3-accounts.queries :as account-queries] [district.ui.web3-tx.events :as tx-events] + [district.ui.web3.queries :as web3-queries] [re-frame.core :as re-frame] + [streamtide.shared.utils :refer [to-base-amount abi-reduced-erc20]] [streamtide.ui.components.error-notification :as error-notification] - [streamtide.ui.events :refer [wallet-chain-interceptors]] + [streamtide.ui.events :as ui-events :refer [wallet-chain-interceptors]] [streamtide.ui.utils :refer [build-tx-opts]] [taoensso.timbre :as log])) @@ -35,16 +38,17 @@ ::distribute ; TX to distribute matching pool for last round wallet-chain-interceptors - (fn [{:keys [db]} [_ {:keys [:send-tx/id :round :matchings] :as data}]] + (fn [{:keys [db]} [_ {:keys [:send-tx/id :round :matchings :coin] :as data}]] (let [tx-name (str "Distribute matching pool for round " round) active-account (account-queries/active-account db) matchings (filter #(> (last %) 0) matchings) [receivers amounts] ((juxt #(map key %) #(map val %)) matchings) - amounts (map str amounts)] + amounts (map str amounts) + token (:coin/address coin)] (log/debug "matchings" matchings) {:dispatch [::tx-events/send-tx {:instance (contract-queries/instance db :streamtide (contract-queries/contract-address db :streamtide-fwd)) :fn :distribute - :args [receivers amounts] + :args [receivers amounts token] :tx-opts (build-tx-opts {:from active-account}) :tx-id {:streamtide/distribute id} :tx-log {:name tx-name @@ -63,36 +67,82 @@ ::distribute] [::error-notification/show-error "Transaction failed"]]}]}))) +(defn matching-pool-tx-input [{:keys [:amount :coin-info :from-address]}] + (if coin-info + (let [from from-address + token (:coin-address coin-info) + amount (to-base-amount amount (:decimals coin-info))] + {:args [from token amount] + :amount-wei nil + :fn :fill-up-matching-pool-token}) + {:args [] + :amount-wei (-> amount str (web3/to-wei :ether))})) + (re-frame/reg-event-fx ::fill-matching-pool ; TX to fill up matching pool wallet-chain-interceptors - (fn [{:keys [db]} [_ {:keys [:send-tx/id :amount :round] :as data}]] - (let [tx-name (str "Filling up matching pool with " amount " ETH") + (fn [{:keys [db]} [_ {:keys [:send-tx/id :amount :round :coin-info :from-address] :as data}]] + (let [symbol (if coin-info (:symbol coin-info) "ETH") + tx-name (str "Filling up matching pool with " amount " " symbol) active-account (account-queries/active-account db) - amount-wei (-> amount str (web3/to-wei :ether))] + {:keys [args amount-wei fn]} (matching-pool-tx-input data)] {:dispatch [::tx-events/send-tx {:instance (contract-queries/instance db :streamtide (contract-queries/contract-address db :streamtide-fwd)) - :args [] + :args args + :fn fn :tx-opts (build-tx-opts {:from active-account :value amount-wei}) :tx-id {:streamtide/fill-matching-pool id} :tx-log {:name tx-name :related-href {:name :route.admin/round :params {:round round}}} :on-tx-success-n [[::logging/info (str tx-name " tx success") ::fill-matching-pool] - [::notification-events/show (str "Matching pool successfully filled with " amount " ETH")]] + [::notification-events/show (str "Matching pool successfully filled with " amount " " symbol)]] :on-tx-error-n [[::logging/error (str tx-name " tx error") {:user {:id active-account} :round round - :amount amount} + :amount amount + :coin-info coin-info} ::fill-matching-pool] [::error-notification/show-error "Transaction failed"]] :on-tx-hash-error-n [[::logging/error (str tx-name " tx error") {:user {:id active-account} :round round - :amount amount} + :amount amount + :coin-info coin-info} ::fill-matching-pool] [::error-notification/show-error "Transaction failed"]]}]}))) +(re-frame/reg-event-fx + ::approve-coin + ; TX to approve streamtide contract to transfer ERC20 token + wallet-chain-interceptors + (fn [{:keys [db]} [_ {:keys [:send-tx/id :amount :round :coin-info]}]] + (let [tx-name "Approve coin" + active-account (account-queries/active-account db) + amount-wei (to-base-amount amount (:decimals coin-info)) + spender (contract-queries/contract-address db :streamtide-fwd)] + {:dispatch [::tx-events/send-tx {:instance (web3-eth/contract-at (web3-queries/web3 db) abi-reduced-erc20 (:coin-address coin-info)) + :fn :approve + :args [spender amount-wei] + :tx-opts (build-tx-opts {:from active-account}) + :tx-id {:streamtide/approve-coin id} + :tx-log {:name tx-name + :related-href {:name :route.admin/round + :params {:round round}}} + :on-tx-success-n [[::logging/info (str tx-name " tx success") ::approve-coin] + [::notification-events/show "Coin successfully approved"] + [::get-allowance coin-info]] + :on-tx-error-n [[::logging/error (str tx-name " tx error") + {:user {:id active-account} + :round round} + ::approve-coin] + [::error-notification/show-error "Transaction failed"]] + :on-tx-hash-error-n [[::logging/error (str tx-name " tx error") + {:user {:id active-account} + :round round} + ::approve-coin] + [::error-notification/show-error "Transaction failed"]]}]}))) + (re-frame/reg-event-fx ::close-round ; TX to close an ongoing round @@ -126,3 +176,67 @@ ::round-closed ; Event triggered when a round is closed. This is an empty event just aimed for subscription (constantly nil)) + +(re-frame/reg-event-fx + ::validate-coin + ; Fetch ERC20 info if valid + [interceptors wallet-chain-interceptors] + (fn [{:keys [db]} [{:keys [:coin-address] :as args}]] + (try + (let [instance (web3-eth/contract-at (web3-queries/web3 db) abi-reduced-erc20 coin-address)] + {:web3/call {:web3 (web3-queries/web3 db) + :fns [{:instance instance + :fn :decimals + :args [] + :on-success [::get-coin-symbol args] + :on-error [::ui-events/dispatch-n [[::logging/error "Cannot fetch ERC20 token details"] + [::error-notification/show-error "Cannot fetch ERC20 token details"]]]}]}}) + (catch :default e + {:dispatch-n [[::logging/error "Cannot parse address" {:error e}] + [::error-notification/show-error "Cannot parse address" e]]})))) + +(re-frame/reg-event-fx + ::get-coin-symbol + ; Get the symbol of an ERC20 token + [interceptors] + (fn [{:keys [db]} [{:keys [:coin-address] :as args} decimals]] + (let [instance (web3-eth/contract-at (web3-queries/web3 db) abi-reduced-erc20 coin-address)] + {:web3/call {:web3 (web3-queries/web3 db) + :fns [{:instance instance + :fn :symbol + :args [] + :on-success [::ui-events/dispatch-n + [[::get-coin-symbol-success (merge args {:decimals decimals})] + [::get-allowance args]]] + :on-error [::ui-events/dispatch-n [[::logging/error "Cannot fetch ERC20 token symbol"] + [::error-notification/show-error "Cannot fetch ERC20 token symbol"]]]}]}}))) + +(re-frame/reg-event-fx + ::get-coin-symbol-success + ; called when fetching a coin name + [interceptors] + (fn [{:keys [db]} [{:keys [:coin-address] :as args} [symbol]]] + {:db (assoc-in db [:coin-info coin-address] (merge args {:symbol symbol}))})) + +(re-frame/reg-event-fx + ::get-allowance + ; Get the allowance the streamtide contract has been given by the current user to spend a given ERC20 token + [interceptors] + (fn [{:keys [db]} [{:keys [:coin-address] :as args}]] + (let [instance (web3-eth/contract-at (web3-queries/web3 db) abi-reduced-erc20 coin-address) + owner (account-queries/active-account db) + spender (contract-queries/contract-address db :streamtide-fwd)] + {:web3/call {:web3 (web3-queries/web3 db) + :fns [{:instance instance + :fn :allowance + :args [owner spender] + :on-success [::get-allowance-success args] + :on-error [::ui-events/dispatch-n [[::logging/error "Cannot fetch ERC20 allowance"] + [::error-notification/show-error "Cannot fetch ERC20 allowance"]]]}]}}))) + +(re-frame/reg-event-fx + ::get-allowance-success + ; called when fetching allowance + [interceptors] + (fn [{:keys [db]} [{:keys [:coin-address]} allowance]] + {:db (update-in db [:coin-info coin-address] #(merge % {:allowance allowance}))})) diff --git a/src/streamtide/ui/admin/round/page.cljs b/src/streamtide/ui/admin/round/page.cljs index f42fe15..ad09fcb 100644 --- a/src/streamtide/ui/admin/round/page.cljs +++ b/src/streamtide/ui/admin/round/page.cljs @@ -3,18 +3,22 @@ (:require [bignumber.core :as bn] [cljsjs.bignumber] - [district.ui.component.form.input :refer [amount-input pending-button]] + [cljs-web3-next.helpers :refer [zero-address]] + [district.ui.component.form.input :refer [amount-input pending-button text-input]] [district.ui.component.page :refer [page]] [district.ui.graphql.events :as graphql-events] [district.ui.graphql.subs :as gql] [district.ui.router.subs :as router-subs] + [district.ui.web3-accounts.subs :as accounts-subs] [district.ui.web3-tx-id.subs :as tx-id-subs] + [reagent.ratom :refer [reaction]] [re-frame.core :refer [subscribe dispatch]] [reagent.core :as r] - [streamtide.shared.utils :as shared-utils] + [streamtide.shared.utils :as shared-utils :refer [to-base-amount]] [streamtide.ui.components.app-layout :refer [app-layout]] [streamtide.ui.admin.round.events :as r-events] [streamtide.ui.admin.round.subs :as r-subs] + [streamtide.ui.components.custom-select :refer [select]] [streamtide.ui.components.general :refer [nav-anchor no-items-found]] [streamtide.ui.components.spinner :as spinner] [streamtide.ui.components.user :refer [user-photo social-links]] @@ -30,8 +34,11 @@ {:round/id id} [:round/start :round/duration - :round/matching-pool - :round/distributed]]) + [:round/matching-pools [[:matching-pool/coin [:coin/symbol + :coin/decimals + :coin/address]] + :matching-pool/amount + :matching-pool/distributed]]]]) (defn build-round-is-last-query [{:keys [:round/id]}] [:search-rounds @@ -47,6 +54,12 @@ {:key "date/asc" :value "Oldest"} {:key "username/asc" :value "Artist Name"}]) +(def other-coin-value "OTHER") + +;; TODO get existing coins from server +(def matching-pool-coin [{:value "ETH" :label "ETH"} + {:value other-coin-value :label "Other"}]) + (defn build-donations-query [{:keys [:round :order-key]} after] (let [[order-by order-dir] ((juxt namespace name) (keyword order-key))] [:search-donations @@ -61,7 +74,8 @@ [:items [:donation/id :donation/date :donation/amount - :donation/coin + [:donation/coin [:coin/symbol + :coin/decimals]] [:donation/receiver [:user/address :user/name :user/photo @@ -115,7 +129,7 @@ [:div.cell.col-date [:span (ui-utils/format-graphql-time date)]] [:div.cell.col-amount - [:span (shared-utils/format-price amount)]] + [:span (shared-utils/format-price amount {:coin/decimals 18 :coin/symbol "ETH"})]] [:div.cell.col-include [:span.checkmark {:on-click #(dispatch [::r-events/enable-donation {:id id :enabled? (not enabled?)}]) :class (when enabled? "checked")}]]])))) @@ -140,7 +154,7 @@ }] [:label {:for key :title (str "factor " value)}]])))])))) -(defn receiver-entry [{:keys [:user/address :user/name :user/photo :user/socials] :as receiver} donations matchings] +(defn receiver-entry [{:keys [:user/address :user/name :user/photo :user/socials] :as receiver} donations matchings coin] (let [nav-receiver (partial nav-anchor {:route :route.profile/index :params {:address (:user/address receiver)}}) matching (get matchings address) disabled? (= matching "0")] @@ -153,7 +167,7 @@ [nav-receiver [:h3 (ui-utils/user-or-address name address)]] [social-links {:socials (filter #(:social/verified %) socials) :class "cel"}]]] - [:div.cell.col-matching [:span (shared-utils/format-price matching)]] + [:div.cell.col-matching [:span (shared-utils/format-price matching coin)]] [:div.cell.col-multiplier [multipliers receiver multiplier-factors]]] [:div.donationsInner @@ -266,7 +280,7 @@ ; (assoc m receiver (* divisor amount))) ; {} amounts))) -(defn donations-entries [round-id round donations-search last-round?] +(defn donations-entries [round-id round matching-pool donations-search last-round?] (let [tx-id (str "distribute_" round-id) distribute-tx-pending (subscribe [::tx-id-subs/tx-pending? {:streamtide/distribute tx-id}]) distribute-tx-success? (subscribe [::tx-id-subs/tx-success? {:streamtide/distribute tx-id}]) @@ -277,8 +291,7 @@ (filter (fn [d] (and (-> d :donation/sender :user/blacklisted not) (-> d :donation/receiver :user/blacklisted not))))) donations-by-receiver (group-by :donation/receiver all-donations) - matching-pool (:round/matching-pool round) - matchings (compute-matchings matching-pool donations-by-receiver + matchings (compute-matchings (:matching-pool/amount matching-pool) donations-by-receiver @(subscribe [::r-subs/all-multipliers]) @(subscribe [::r-subs/all-donations]))] [:<> @@ -287,38 +300,55 @@ [:div.donations (doall (for [[receiver donations] donations-by-receiver] - ^{:key (:user/address receiver)} [receiver-entry receiver donations matchings]))]) + ^{:key (:user/address receiver)} [receiver-entry receiver donations matchings (:matching-pool/coin matching-pool)]))]) (when (and (not (round-open? round)) ; round needs to be closed ... - (= "0" (:round/distributed round)) ; ... and not distributed yet - (not= "0" (:round/matching-pool round)) ; ... and has something to distribute + (= "0" (:matching-pool/distributed matching-pool)) ; ... and not distributed yet + (not= "0" (:matching-pool/amount matching-pool)) ; ... and has something to distribute last-round? ; ... be the last existing round ) [pending-button {:pending? (or @distribute-tx-pending @waiting-wallet?) :pending-text "Distributing" :disabled (or @distribute-tx-pending @distribute-tx-success? @waiting-wallet?) - :class (str "btBasic btBasic-light btDistribute" (when-not @distribute-tx-success? " distributed")) + :class (str "btBasic btBasic-light btDistribute" (when @distribute-tx-success? " btDistributed")) :on-click (fn [e] (.stopPropagation e) (dispatch [::r-events/distribute {:send-tx/id tx-id :round round-id - :matchings matchings}]))} + :matchings matchings + :coin (:matching-pool/coin matching-pool)}]))} (if @distribute-tx-success? "Distributed" "Distribute")])])) (defn donations [round-id round last-round?] - (let [form-data (r/atom {:round round-id - :order-key (:key (first donations-order))})] + (let [coins (map #(let [coin (:matching-pool/coin %)] + {:value (:coin/address coin) + :label (:coin/symbol coin)}) + (:round/matching-pools round)) + form-data (r/atom {:round round-id + :order-key (:key (first donations-order)) + :coin (:value (first coins))})] (fn [] (let [donations-search (subscribe [::gql/query {:queries [(build-donations-query @form-data nil)]} {:id @form-data}]) loading? (:graphql/loading? (last @donations-search)) has-more? (-> (last @donations-search) :search-donations :has-next-page) end-cursor (-> (last @donations-search) :search-donations :end-cursor) + matching-pool (first (filter #(= (:coin @form-data) (-> % :matching-pool/coin :coin/address)) (:round/matching-pools round))) ; makes sure all items are loaded _ (when (and (not loading?) has-more?) (dispatch [::graphql-events/query {:query {:queries [(build-donations-query @form-data end-cursor)]} :id @form-data}]))] [:div.contentDonation [:h2 "Donations"] + [:div.coin-container "Matching pool coin:" + (if + (< (count coins) 2) + [:div.inputField.simple.coin.disabled + [:span (-> coins first :label)]] + [:div.custom-select.selectForm.coin + [select {:form-data form-data + :id :coin + :options coins + :class "options"}]])] [:div.headerReceivers.d-none.d-md-flex [:div.cel-data [:span.titleCel.col-receiver "Receiver"] @@ -327,23 +357,33 @@ (if (or loading? has-more?) [spinner/spin] - [donations-entries round-id round donations-search last-round?])])))) + [donations-entries round-id round matching-pool donations-search last-round?])])))) (defmethod page :route.admin/round [] (let [active-page-sub (subscribe [::router-subs/active-page]) round-id (-> @active-page-sub :params :round) - form-data (r/atom {:amount 0}) + form-data (r/atom {:amount 0 + :matching-pool-coin (:value (first matching-pool-coin))}) + errors (reaction {:local (cond-> {} + (and (:coin-address @form-data) + (not (ui-utils/valid-address-format? (:coin-address @form-data)))) + (assoc :coin-address "Address not valid") + (not (or (empty? (:from-address @form-data)) + (ui-utils/valid-address-format? (:from-address @form-data)))) + (assoc :from-address "Address not valid"))}) tx-id-mp (str "match-pool_" (random-uuid)) - tx-id-cr (str "close-round_" (random-uuid))] + tx-id-cr (str "close-round_" (random-uuid)) + tx-id-ac (str "approve-coin_" (random-uuid))] (fn [] (let [round-info-query (subscribe [::gql/query {:queries [(build-round-info-query {:round/id round-id})]} {:refetch-on [::r-events/round-closed]}]) build-round-is-last-query (subscribe [::gql/query {:queries [(build-round-is-last-query {:round/id round-id})]}]) loading? (or (:graphql/loading? @round-info-query) (:graphql/loading? @build-round-is-last-query)) round (:round @round-info-query) - {:keys [:round/start :round/duration :round/matching-pool :round/distributed]} round - last-round? (-> @build-round-is-last-query :search-rounds :has-next-page not)] + {:keys [:round/start :round/duration :round/matching-pools :round/distributed]} round + last-round? (-> @build-round-is-last-query :search-rounds :has-next-page not) + active-account @(subscribe [::accounts-subs/active-account])] [app-layout [:main.pageSite.pageRound {:id "round"} @@ -360,34 +400,93 @@ (str "Status: " status)]) [:div.start (str "Start Time: " (ui-utils/format-graphql-time start))] [:div.end (str "End Time: " (ui-utils/format-graphql-time (+ start duration)))] - [:div.matching (str "Matching pool: " (shared-utils/format-price matching-pool))] - [:div.distributed (str "Distributed amount: " (shared-utils/format-price distributed))] + [:div.matching "Matching pool:" [:div.amounts (map (fn [mp] [:span.amount {:key (-> mp :matching-pool/coin :coin/address)} (shared-utils/format-price (:matching-pool/amount mp) (:matching-pool/coin mp))] ) matching-pools)]] + [:div.distributed "Distributed amount:" [:div.amounts (map (fn [mp] [:span.amount {:key (-> mp :matching-pool/coin :coin/address)} (shared-utils/format-price (:matching-pool/distributed mp) (:matching-pool/coin mp))] ) matching-pools)]] (when (round-open? round) (let [match-pool-tx-pending? (subscribe [::tx-id-subs/tx-pending? {:streamtide/fill-matching-pool tx-id-mp}]) match-pool-tx-success? (subscribe [::tx-id-subs/tx-success? {:streamtide/fill-matching-pool tx-id-mp}]) match-pool-waiting-wallet? (subscribe [::st-subs/waiting-wallet? {:streamtide/fill-matching-pool tx-id-mp}]) close-round-tx-pending? (subscribe [::tx-id-subs/tx-pending? {:streamtide/close-round tx-id-cr}]) close-round-tx-success? (subscribe [::tx-id-subs/tx-success? {:streamtide/close-round tx-id-cr}]) - close-round-waiting-wallet? (subscribe [::st-subs/waiting-wallet? {:streamtide/close-round tx-id-cr}])] + close-round-waiting-wallet? (subscribe [::st-subs/waiting-wallet? {:streamtide/close-round tx-id-cr}]) + approve-coin-tx-pending? (subscribe [::tx-id-subs/tx-pending? {:streamtide/approve-coin tx-id-ac}]) + approve-coin-tx-success? (subscribe [::tx-id-subs/tx-success? {:streamtide/approve-coin tx-id-ac}]) + approve-coin-waiting-wallet? (subscribe [::st-subs/waiting-wallet? {:streamtide/approve-coin tx-id-ac}]) + coin-info (subscribe [::r-subs/coin-info (:coin-address @form-data)])] [:<> [:div.form.fillPoolForm [:label.inputField [:span "Amount"] [amount-input {:id :amount :form-data form-data}]] + [:div.custom-select.selectForm + [select {:form-data form-data + :id :matching-pool-coin + :options matching-pool-coin + :class "options"}]] + (when (= (:matching-pool-coin @form-data) other-coin-value) + [:<> + [:label.inputField.coin + [:span "Coin Address"] + [text-input {:id :coin-address + :placeholder zero-address + :form-data form-data + :errors errors}]] + (if @coin-info + [:span.coin-symbol (:symbol @coin-info)] + [:button.btBasic.btBasic-light.btValidateCoin + {:on-click #(dispatch [::r-events/validate-coin {:coin-address (:coin-address @form-data)}]) + :disabled (or (not (:coin-address @form-data)) + (not (ui-utils/valid-address-format? (:coin-address @form-data))))} + "Validate"]) + [:label.inputField.coin + [:span "From"] + [text-input {:id :from-address + :placeholder active-account + :form-data form-data + :errors errors}]] + (when (and @coin-info + (or (empty? (:from-address @form-data)) + (= (:from-address @form-data) active-account))) + (if + (bn/>= (new-bn (:allowance @coin-info)) (new-bn (to-base-amount (:amount @form-data) (:decimals @coin-info)))) + [:button.btBasic.btBasic-light.btApprovedCoin {:disabled true} "Approved"] + [pending-button {:pending? (or @approve-coin-tx-pending? @approve-coin-waiting-wallet?) + :pending-text "Approving" + :disabled (or @approve-coin-tx-pending? @approve-coin-tx-success? @approve-coin-waiting-wallet? + (>= 0 (:amount @form-data)) + (not (or (empty? (:from-address @form-data)) + (ui-utils/valid-address-format? (:from-address @form-data))))) + :class (str "btBasic btBasic-light btApproveCoin") + :on-click (fn [e] + (.stopPropagation e) + (dispatch [::r-events/approve-coin {:send-tx/id tx-id-ac + :amount (:amount @form-data) + :round round + :coin-info @coin-info}]))} + (if @approve-coin-tx-success? "Approved" "Approve")]))])] + [:div.buttons [pending-button {:pending? (or @match-pool-tx-pending? @match-pool-waiting-wallet?) :pending-text "Filling Up Matching Pool" :disabled (or @match-pool-tx-pending? @match-pool-tx-success? @match-pool-waiting-wallet? @close-round-tx-pending? @close-round-tx-success? @close-round-waiting-wallet? - (>= 0 (:amount @form-data))) + (>= 0 (:amount @form-data)) + (and (= (:matching-pool-coin @form-data) other-coin-value) + (or (not @coin-info) + (not (or (empty? (:from-address @form-data)) + (ui-utils/valid-address-format? (:from-address @form-data))))))) :class (str "btBasic btBasic-light btMatchPool") :on-click (fn [e] (.stopPropagation e) (dispatch [::r-events/fill-matching-pool {:send-tx/id tx-id-mp :amount (:amount @form-data) - :round round}]))} - (if @match-pool-tx-success? "Matching Pool Filled up" "Fill Up Matching Pool")]] - [:div.form.closeRoundForm + :round round + :coin-info (when (= (:matching-pool-coin @form-data) + other-coin-value) + @coin-info) + :from-address (let [from-address (:from-address @form-data)] + (if (empty? from-address) active-account from-address))}]))} + (if @match-pool-tx-success? "Matching Pool Filled up" "Fill Up Matching Pool")] [pending-button {:pending? (or @close-round-tx-pending? @close-round-waiting-wallet?) :pending-text "Closing Round" :disabled (or @close-round-tx-pending? @close-round-tx-success? @close-round-waiting-wallet?) @@ -396,6 +495,5 @@ (.stopPropagation e) (dispatch [::r-events/close-round {:send-tx/id tx-id-cr :round round}]))} - (if @close-round-tx-success? "Round Closed" "Close Round")]]] - )) + (if @close-round-tx-success? "Round Closed" "Close Round")]]])) [donations round-id round last-round?]])]]])))) diff --git a/src/streamtide/ui/admin/round/subs.cljs b/src/streamtide/ui/admin/round/subs.cljs index 0ecbd8b..0aa955a 100644 --- a/src/streamtide/ui/admin/round/subs.cljs +++ b/src/streamtide/ui/admin/round/subs.cljs @@ -21,3 +21,8 @@ ::all-donations (fn [db [_]] (get db :donations))) + +(re-frame/reg-sub + ::coin-info + (fn [db [_ coin-address]] + (get-in db [:coin-info coin-address]))) diff --git a/src/streamtide/ui/admin/rounds/page.cljs b/src/streamtide/ui/admin/rounds/page.cljs index b012c79..d370a5b 100644 --- a/src/streamtide/ui/admin/rounds/page.cljs +++ b/src/streamtide/ui/admin/rounds/page.cljs @@ -33,36 +33,42 @@ [:items [:round/id :round/start :round/duration - :round/matching-pool - :round/distributed]]]]) + [:round/matching-pools [[:matching-pool/coin [:coin/symbol + :coin/decimals]] + :matching-pool/amount + :matching-pool/distributed]]]]]]) -(defn round-entry [{:keys [:round/id :round/start :round/matching-pool :round/duration :round/distributed] :as round}] +(defn round-entry [{:keys [:round/id :round/start :round/duration :round/matching-pools] :as round}] (let [nav (partial nav-anchor {:route :route.admin/round :params {:round id}}) active? (shared-utils/active-round? round (shared-utils/now-secs))] - [:div.contentRound + [nav [:div.contentRound (when active? {:class "active"}) [:div.cel.name [:h4.d-lg-none "ID"] - [nav [:h4 id]]] + [:h4 id]] [:div.cel.start [:h4.d-lg-none "Start Date"] - [nav [:span (ui-utils/format-graphql-time start)]]] + [:span (ui-utils/format-graphql-time start)]] [:div.cel.duration [:h4.d-lg-none "End Date"] - [nav [:span (ui-utils/format-graphql-time (+ start duration))]]] + [:span (ui-utils/format-graphql-time (+ start duration))]] [:div.cel.matching-pool [:h4.d-lg-none "Matching Pool"] - [nav [:span (shared-utils/format-price matching-pool)]]] + [:div.amounts + (map (fn [mp] + [:span {:key (-> mp :matching-pool/coin :coin/symbol)} (shared-utils/format-price (:matching-pool/amount mp) (:matching-pool/coin mp))]) matching-pools)]] [:div.cel.distributed [:h4.d-lg-none "Distributed"] - [nav [:span (shared-utils/format-price distributed)]]]])) + [:div.amounts + (map (fn [mp] + [:span {:key (-> mp :matching-pool/coin :coin/symbol)}(shared-utils/format-price (:matching-pool/distributed mp) (:matching-pool/coin mp))]) matching-pools)]]]])) (defn round-entries [rounds-search] (let [all-rounds (->> @rounds-search (mapcat (fn [r] (-> r :search-rounds :items))) distinct - (sort-by #(:round/id %)) + (sort-by #(int (:round/id %))) reverse) loading? (:graphql/loading? (last @rounds-search)) has-more? (-> (last @rounds-search) :search-rounds :has-next-page)] diff --git a/src/streamtide/ui/components/error_notification.cljs b/src/streamtide/ui/components/error_notification.cljs index 8934675..16854db 100644 --- a/src/streamtide/ui/components/error_notification.cljs +++ b/src/streamtide/ui/components/error_notification.cljs @@ -23,7 +23,8 @@ (defn- parse-error [js-error] (let [js-error (unwrap-error js-error)] - (let [{:keys [:message :error :code :status]} (js->clj js-error :keywordize-keys true)] + (let [{:keys [:message :error :code :status]} (js->clj js-error :keywordize-keys true) + message (or message (.-message js-error))] (if (or (and code (< code 0) message) (= status :tx.status/error)) "Transaction reverted" (or message error))))) diff --git a/src/streamtide/ui/leaderboard/page.cljs b/src/streamtide/ui/leaderboard/page.cljs index c54cbb5..ba3479c 100644 --- a/src/streamtide/ui/leaderboard/page.cljs +++ b/src/streamtide/ui/leaderboard/page.cljs @@ -39,8 +39,12 @@ :end-cursor :has-next-page [:items [:leader/donation-amount - :leader/matching-amount - :leader/total-amount + [:leader/matching-amounts [:amount + [:coin [:coin/symbol + :coin/decimals]]]] + [:leader/total-amounts [:amount + [:coin [:coin/symbol + :coin/decimals]]]] [:leader/receiver [:user/address :user/name :user/photo @@ -51,7 +55,7 @@ {:first 100} [[:items [:round/id]]]]) -(defn leaderboard-entry [{:keys [:leader/receiver :leader/donation-amount :leader/matching-amount :leader/total-amount]}] +(defn leaderboard-entry [{:keys [:leader/receiver :leader/donation-amount :leader/matching-amounts :leader/total-amounts]}] (let [nav (partial nav-anchor {:route :route.profile/index :params {:address (:user/address receiver)}})] [:div.leaderboard [nav [user-photo {:class (str "lb" (when (:user/unlocked receiver) " star")) :src (:user/photo receiver)}]] @@ -60,13 +64,18 @@ [:ul.score [:li [:h4.d-md-none "Amount Granted"] - [:span (shared-utils/format-price donation-amount)]] + [:span (shared-utils/format-price donation-amount {:coin/decimals 18 :coin/symbol "ETH"})]] [:li [:h4.d-md-none "Matching Received"] - [:span (shared-utils/format-price matching-amount)]] + + [:div.amounts + (map (fn [mp] + [:span {:key (-> mp :coin :coin/symbol)} (shared-utils/format-price (:amount mp) (:coin mp))]) matching-amounts)]] [:li [:h4.d-md-none "Total Received"] - [:span (shared-utils/format-price total-amount)]]]])) + [:div.amounts + (map (fn [mp] + [:span {:key (-> mp :coin :coin/symbol)} (shared-utils/format-price (:amount mp) (:coin mp))]) total-amounts)]]]])) (defn leaderboard-entries [form-data leaders-search] (let [active-session (subscribe [::st-subs/active-session]) diff --git a/src/streamtide/ui/send_support/page.cljs b/src/streamtide/ui/send_support/page.cljs index ab074c7..d25ead6 100644 --- a/src/streamtide/ui/send_support/page.cljs +++ b/src/streamtide/ui/send_support/page.cljs @@ -103,7 +103,7 @@ [:span (ui-utils/format-graphql-time date)]] [:li [:h4.d-lg-none "Amount"] - [:span (shared-utils/format-price amount)]]]])) + [:span (shared-utils/format-price amount {:coin/decimals 18 :coin/symbol "ETH"})]]]])) (defn donations [] (let [active-account (subscribe [::accounts-subs/active-account])] @@ -175,7 +175,7 @@ :disabled (or @donate-tx-pending? @donate-tx-success? @waiting-wallet? (empty? @form-data) (some #(or (zero? %) (nil? %)) (map :amount (vals @form-data)))) - :class (str "btBasic btBasic-light btCheckout" (when-not @donate-tx-success? " checkedOut")) + :class (str "btBasic btBasic-light btCheckout" (when @donate-tx-success? " checkedOut")) :on-click (fn [e] (.stopPropagation e) (dispatch [::ss-events/send-support {:donations @form-data diff --git a/src/streamtide/ui/utils.cljs b/src/streamtide/ui/utils.cljs index 62d6561..4ed4105 100644 --- a/src/streamtide/ui/utils.cljs +++ b/src/streamtide/ui/utils.cljs @@ -56,3 +56,6 @@ {:maxPriorityFeePerGas nil :maxFeePerGas nil} opts)) + +(defn valid-address-format? [address] + (re-matches #"^0x[a-fA-F0-9]{40}$" address)) diff --git a/test/tests/contract/syncer_test.cljs b/test/tests/contract/syncer_test.cljs index da06b49..fb1a106 100644 --- a/test/tests/contract/syncer_test.cljs +++ b/test/tests/contract/syncer_test.cljs @@ -1,6 +1,7 @@ (ns tests.contract.syncer-test (:require [cljs-web3-next.eth :as web3-eth] [cljs-web3-next.evm :as web3-evm] + [cljs-web3-next.helpers :refer [zero-address]] [cljs.core.async :refer [ round-id 0)) - (is (= (:round/matching-pool round) "1000")) - (is (= (:round/distributed round) "0")) + (is (= (:matching-pool/amount matching-pool) "1000")) + (is (or (= (:matching-pool/distributed matching-pool) "0") + (= (:matching-pool/distributed matching-pool) nil))) (is (> (:round/start round) 0)) ( (db/get-rounds {:first 6}) + :items + first + :round/id + (db/get-matching-pool zero-address) + :matching-pool/amount) + "3500")) (db/upsert-user-info! {:user/address user :user/min-donation "300"}) @@ -121,7 +131,7 @@ (is (= (:donation/receiver donation) user)) (is (= (str (:donation/amount donation)) "250")) (is (> (:donation/date donation) 0)) - (is (= (:donation/coin donation) "eth")) + (is (= (:donation/coin donation) zero-address)) (is (= (:round/id donation) round-id))) (web3-evm/increase-time! @@ -140,23 +150,24 @@ (is (= (:donation/receiver donation) user)) (is (= (str (:donation/amount donation)) "500")) (is (> (:donation/date donation) 0)) - (is (= (:donation/coin donation) "eth")) + (is (= (:donation/coin donation) zero-address)) (is (nil? (:round/id donation)))) - ( (:matching/date matching) 0)) - (is (= (:matching/coin matching) "eth")) + (is (= (:matching/coin matching) zero-address)) (is (= (:round/id matching) round-id)) - (is (= (:round/distributed round) "1000"))) + (is (= (:matching-pool/distributed matching-pool) "1000"))) (