diff --git a/app/assets/v2/js/cart-ethereum-polygon.js b/app/assets/v2/js/cart-ethereum-polygon.js index 822f968cfe5..61758550e47 100644 --- a/app/assets/v2/js/cart-ethereum-polygon.js +++ b/app/assets/v2/js/cart-ethereum-polygon.js @@ -1,7 +1,3 @@ -const bulkCheckoutAddressPolygon = appCart.$refs.cart.network === 'mainnet' - ? '0xb99080b9407436eBb2b8Fe56D45fFA47E9bb8877' - : '0x3E2849E2A489C8fE47F52847c42aF2E8A82B9973'; - function objectMap(object, mapFn) { return Object.keys(object).reduce(function(result, key) { result[key] = mapFn(object[key]); @@ -138,6 +134,12 @@ Vue.component('grantsCartEthereumPolygon', { this.polygon.checkoutStatus = 'depositing'; }, + getBulkCheckoutAddress() { + return appCart.$refs.cart.network === 'mainnet' + ? '0xb99080b9407436eBb2b8Fe56D45fFA47E9bb8877' + : '0x3E2849E2A489C8fE47F52847c42aF2E8A82B9973'; + }, + handleError(e) { appCart.$refs.cart.handleError(e); }, @@ -238,6 +240,8 @@ Vue.component('grantsCartEthereumPolygon', { } } else if (switchError.code === 4001) { throw new Error('Please connect MetaMask to Polygon network.'); + } else if (switchError.code === -32002) { + throw new Error('Please respond to a pending MetaMask request.'); } else { console.error(switchError); } @@ -246,6 +250,8 @@ Vue.component('grantsCartEthereumPolygon', { // Send a batch transfer based on donation inputs async checkoutWithPolygon() { + const bulkCheckoutAddressPolygon = this.getBulkCheckoutAddress(); + try { if (typeof ga !== 'undefined') { @@ -311,6 +317,8 @@ Vue.component('grantsCartEthereumPolygon', { }, async sendDonationTx(userAddress) { + const bulkCheckoutAddressPolygon = this.getBulkCheckoutAddress(); + // Get our donation inputs const bulkTransaction = new web3.eth.Contract(bulkCheckoutAbi, bulkCheckoutAddressPolygon); const donationInputsFiltered = this.getDonationInputs(); @@ -334,11 +342,78 @@ Vue.component('grantsCartEthereumPolygon', { // Estimates the total gas cost of a polygon checkout and sends it to cart.js async estimateGasCost() { - // The below heuristics are used instead of `estimateGas()` so we can send the donation - // transaction before the approval txs are confirmed, because if the approval txs - // are not confirmed then estimateGas will fail. + /** + * The below heuristics are used instead of `estimateGas()` so we can send the donation + * transaction before the approval txs are confirmed, because if the approval txs + * are not confirmed then estimateGas will fail. + */ + + let networkId = appCart.$refs.cart.networkId; + + if (networkId !== '80001' && networkId !== '137' && appCart.$refs.cart.chainId !== '1' || this.cart.unsupportedTokens.length > 0) { + return; + } - return 70000; + let gasLimit = 0; + + // If user has enough balance within Polygon, cost equals the minimum amount + let { isBalanceSufficient, requiredAmounts } = await this.hasEnoughBalanceInPolygon(); + + if (!isBalanceSufficient) { + // If we're here, user needs at least one L1 deposit, so let's calculate the total cost + requiredAmounts = objectMap(requiredAmounts, value => { + if (value.isBalanceSufficient == false) { + return value.amount; + } + }); + + for (const tokenSymbol in requiredAmounts) { + /** + * The below estimates were got by analyzing gas usages for deposit transactions + * on the RootChainManagerProxy contract. View the link below, + * https://goerli.etherscan.io/address/0xbbd7cbfa79faee899eaf900f13c9065bf03b1a74 + */ + if (tokenSymbol === 'ETH') { + gasLimit += 94659; // add ~94.66k gas for ETH deposits + } else { + gasLimit += 103000; // add 103k gas for token deposits + } + } + } + + // If we have a cart where all donations are in Dai, we use a linear regression to + // estimate gas costs based on real checkout transaction data, and add a 50% margin + const donationCurrencies = this.donationInputs.map(donation => donation.token); + const daiAddress = this.getTokenByName('DAI')?.addr; + const isAllDai = donationCurrencies.every((addr) => addr === daiAddress); + + if (isAllDai) { + if (donationCurrencies.length === 1) { + // Special case since we overestimate here otherwise + return gasLimit + 65000; + } + // The Below curve found by running script with the repo https://github.com/mds1/Gitcoin-Checkout-Gas-Analysis. + // View the chart here -> https://chart-studio.plotly.com/~chibie/1/ + return gasLimit + 10000 * donationCurrencies.length + 80000; + } + + /** + * Otherwise, based on contract tests, we use the more conservative heuristic below to get + * a gas estimate. The estimates used here are based on testing the cost of a single + * donation (i.e. one item in the cart). Because gas prices go down with batched + * transactions, whereas this assumes they're constant, this gives us a conservative estimate + */ + gasLimit += this.donationInputs.reduce((accumulator, currentValue) => { + // const tokenAddr = currentValue.token?.toLowerCase(); + + if (currentValue.token === MATIC_ADDRESS) { + return accumulator + 25000; // MATIC donation gas estimate + } + + return accumulator + 70000; // generic token donation gas estimate + }, 0); + + return gasLimit; }, // Returns true if user has enough balance within Polygon to avoid L1 deposit, false otherwise @@ -383,22 +458,24 @@ Vue.component('grantsCartEthereumPolygon', { } // Check if user has enough MATIC to cover gas costs - const gasFeeInWei = web3.utils.toWei( - (this.polygon.estimatedGasCost * 2).toString(), 'gwei' // using 2 gwei as gas price - ); - - if (userMaticBalance.lt(gasFeeInWei)) { - let requiredAmount = parseFloat(Number( - web3.utils.fromWei((gasFeeInWei - userMaticBalance).toString(), 'ether') - ).toFixed(5)); - - if (requiredAmounts['MATIC']) { - requiredAmounts['MATIC'].amount += requiredAmount; - } else { - requiredAmounts['MATIC'] = { - amount: requiredAmount, - isBalanceSufficient: false - }; + if (this.polygon.estimatedGasCost) { + const gasFeeInWei = web3.utils.toWei( + (this.polygon.estimatedGasCost * 2).toString(), 'gwei' // using 2 gwei as gas price + ); + + if (userMaticBalance.lt(gasFeeInWei)) { + let requiredAmount = parseFloat(Number( + web3.utils.fromWei((gasFeeInWei - userMaticBalance).toString(), 'ether') + ).toFixed(5)); + + if (requiredAmounts['MATIC']) { + requiredAmounts['MATIC'].amount += requiredAmount; + } else { + requiredAmounts['MATIC'] = { + amount: requiredAmount, + isBalanceSufficient: false + }; + } } } diff --git a/app/assets/v2/js/cart.js b/app/assets/v2/js/cart.js index 9301ca98a88..5e99b28e11c 100644 --- a/app/assets/v2/js/cart.js +++ b/app/assets/v2/js/cart.js @@ -11,19 +11,7 @@ let appCart; document.addEventListener('dataWalletReady', async function(e) { appCart.$refs['cart'].network = networkName; - appCart.$refs['cart'].sourceNetwork = networkName; appCart.$refs['cart'].networkId = String(Number(web3.eth.currentProvider.chainId)); - if (appCart.$refs.cart.autoSwitchNetwork) { - try { - await ethereum.request({ - method: 'wallet_switchEthereumChain', - params: [{ chainId: networkName == 'mainnet' ? '0x1' : '0x4' }] - }); // mainnet or rinkeby - appCart.$refs.cart.autoSwitchNetwork = false; - } catch (e) { - console.log(e); - } - } }, false); // needWalletConnection(); @@ -57,12 +45,10 @@ Vue.component('grants-cart', { { text: 'Wallet address', value: 'address' }, { text: 'Transaction Hash', value: 'txid' } ], - autoSwitchNetwork: true, - checkoutRecommendationIsCompleted: false, + standardCheckoutInitiated: false, chainId: '', networkId: '', network: 'mainnet', - sourceNetwork: 'mainnet', tabSelected: 'ETH', tabIndex: null, currentTokens: [], // list of all available tokens @@ -392,24 +378,19 @@ Vue.component('grants-cart', { const estimateZkSync = Number(this.zkSyncEstimatedGasCost); // zkSync gas cost estimate const estimatePolygon = Number(this.polygonEstimatedGasCost); // polygon gas cost estimate - const exit = (recommendation) => { - this.checkoutRecommendationIsCompleted = true; - return recommendation; - }; - const compareWithL2 = (estimateL2, name) => { if (estimateL1 < estimateL2) { const savingsInGas = estimateL2 - estimateL1; const savingsInPercent = Math.round(savingsInGas / estimateL2 * 100); - return exit({ name: 'Standard checkout', savingsInGas, savingsInPercent }); + return { name: 'Standard checkout', savingsInGas, savingsInPercent }; } const savingsInGas = estimateL1 - estimateL2; const percentSavings = savingsInGas / estimateL1 * 100; const savingsInPercent = percentSavings > 99 ? 99 : Math.round(percentSavings); // max value of 99% - return exit({ name, savingsInGas, savingsInPercent }); + return { name, savingsInGas, savingsInPercent }; }; zkSyncComparisonResult = compareWithL2(estimateZkSync, 'zkSync'); @@ -418,12 +399,12 @@ Vue.component('grants-cart', { polygonSavings = polygonComparisonResult.name === 'Polygon' ? polygonComparisonResult.savingsInPercent : 0; if (zkSyncSavings > polygonSavings) { - return exit(zkSyncComparisonResult); + return zkSyncComparisonResult; } else if (zkSyncSavings < polygonSavings) { - return exit(polygonComparisonResult); + return polygonComparisonResult; } - return exit(zkSyncComparisonResult); // recommendation will be standard checkout + return zkSyncComparisonResult; // recommendation will be standard checkout }, isHarmonyExtInstalled() { @@ -809,8 +790,6 @@ Vue.component('grants-cart', { * @param {String} name Token name, e.g. ETH or DAI */ getTokenByName(name, isPolygon = false) { - let token; - if (name === 'ETH' && !isPolygon) { return { addr: ETH_ADDRESS, @@ -836,9 +815,7 @@ Vue.component('grants-cart', { return token; } - token = this.filterByChainId.filter(token => token.name === name)[0]; - - return token; + return this.filterByChainId.filter(token => token.name === name)[0]; }, async applyAmountToAllGrants(grant) { @@ -876,6 +853,26 @@ Vue.component('grants-cart', { return await onConnect(); } + let supportedTestnets = [ 'rinkeby', 'goerli', 'kovan', 'ropsten' ]; + + if (!supportedTestnets.includes(networkName) || this.networkId !== '1') { + // User MetaMask must be connected to Ethereum mainnet or a supported testnet + try { + await ethereum.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x1' }] + }); + } catch (switchError) { + if (switchError.code === 4001) { + throw new Error('Please connect MetaMask to Ethereum network.'); + } else if (switchError.code === -32002) { + throw new Error('Please respond to a pending MetaMask request.'); + } else { + console.error(switchError); + } + } + } + if (typeof ga !== 'undefined') { ga('send', 'event', 'Grant Checkout', 'click', 'Person'); } @@ -1040,6 +1037,8 @@ Vue.component('grants-cart', { // Standard L1 checkout flow async standardCheckout() { + this.standardCheckoutInitiated = true; + try { // Setup ----------------------------------------------------------------------------------- this.isCheckoutOngoing = true; @@ -1066,6 +1065,7 @@ Vue.component('grants-cart', { } catch (err) { this.handleError(err); } + this.standardCheckoutInitiated = false; }, /** diff --git a/app/dashboard/utils.py b/app/dashboard/utils.py index ee435439ffa..1e8b5d70c7a 100644 --- a/app/dashboard/utils.py +++ b/app/dashboard/utils.py @@ -284,7 +284,7 @@ def ipfs_cat_requests(key): return None, 500 -def get_web3(network, sockets=False): +def get_web3(network, sockets=False, chain='std'): """Get a Web3 session for the provided network. Attributes: @@ -298,8 +298,13 @@ def get_web3(network, sockets=False): web3.main.Web3: A web3 instance for the provided network. """ - if network in ['mainnet', 'rinkeby', 'ropsten']: - if sockets: + if network in ['mainnet', 'rinkeby', 'ropsten', 'testnet']: + if network == 'mainnet' and chain == 'polygon': + network = 'polygon-mainnet' + elif network == 'testnet': + network = 'polygon-mumbai' + + if sockets and chain != 'polygon': # polygon doesn't yet have socket support in infura if settings.INFURA_USE_V3: provider = WebsocketProvider(f'wss://{network}.infura.io/ws/v3/{settings.INFURA_V3_PROJECT_ID}') else: @@ -925,12 +930,12 @@ def is_valid_eth_address(eth_address): return (bool(re.match(r"^0x[a-zA-Z0-9]{40}$", eth_address)) or eth_address == "0x0") -def get_tx_status(txid, network, created_on): - status, timestamp, tx = get_tx_status_and_details(txid, network, created_on) +def get_tx_status(txid, network, created_on, chain='std'): + status, timestamp, tx = get_tx_status_and_details(txid, network, created_on, chain=chain) return status, timestamp -def get_tx_status_and_details(txid, network, created_on): +def get_tx_status_and_details(txid, network, created_on, chain='std'): from django.utils import timezone import pytz @@ -944,7 +949,7 @@ def get_tx_status_and_details(txid, network, created_on): if txid == 'override': return 'success', None #overridden by admin try: - web3 = get_web3(network) + web3 = get_web3(network, chain=chain) tx = web3.eth.getTransactionReceipt(txid) if not tx: drop_dead_date = created_on + timezone.timedelta(days=DROPPED_DAYS) diff --git a/app/economy/tx.py b/app/economy/tx.py index c899875fbe2..a884bdd1517 100644 --- a/app/economy/tx.py +++ b/app/economy/tx.py @@ -3,9 +3,7 @@ from django.conf import settings from django.utils import timezone -import requests from dashboard.abi import erc20_abi -from dashboard.utils import get_tx_status, get_web3 from economy.models import Token from web3 import Web3 from web3.exceptions import BadFunctionCallOutput @@ -107,39 +105,51 @@ def parse_token_amount(token_symbol, amount, network): parsed_amount = int(amount * 10 ** decimals) return parsed_amount -def check_for_replaced_tx(tx_hash, network): +def check_for_replaced_tx(tx_hash, network, datetime=None, chain='std'): """ Get status of the provided transaction hash, and look for a replacement transaction hash. If a replacement exists, return the status and hash of the new transaction """ - status, timestamp = get_tx_status(tx_hash, network, timezone.now()) + from dashboard.utils import get_tx_status + + if not datetime: + datetime = timezone.now() + + status, timestamp = get_tx_status(tx_hash, network, datetime, chain=chain) if status in ['pending', 'dropped', 'unknown', '']: new_tx = getReplacedTX(tx_hash) if new_tx: tx_hash = new_tx - status, timestamp = get_tx_status(tx_hash, network, timezone.now()) + status, timestamp = get_tx_status(tx_hash, network, datetime) return tx_hash, status, timestamp -def grants_transaction_validator(contribution, w3): +def grants_transaction_validator(contribution, w3, chain='std'): """ - This function is used to validate contributions sent on L1 through the BulkCheckout contract. + This function is used to validate contributions sent on L1 & Polygon L2 through the BulkCheckout contract. This contract can be found here: - On GitHub: https://github.com/gitcoinco/BulkTransactions/blob/master/contracts/BulkCheckout.sol - On mainnet: https://etherscan.io/address/0x7d655c57f71464b6f83811c55d84009cd9f5221c + - On Polygon mainnet: https://polygonscan.com/address/0xb99080b9407436eBb2b8Fe56D45fFA47E9bb8877 + - On Polygon testnet: https://mumbai.polygonscan.com/address/0x3E2849E2A489C8fE47F52847c42aF2E8A82B9973 - To facilitate testing on Rinkeby, we pass in a web3 instance instead of using the mainnet + To facilitate testing on Rinkeby and Mumbai, we pass in a web3 instance instead of using the mainnet instance defined at the top of this file """ - # Get bulk checkout contract instance - bulk_checkout_contract = w3.eth.contract(address=bulk_checkout_address, abi=bulk_checkout_abi) - # Get specific info about this contribution that we use later tx_hash = contribution.split_tx_id network = contribution.subscription.network + if network == 'mainnet' and chain == 'polygon': + bulk_checkout_address = '0xb99080b9407436eBb2b8Fe56D45fFA47E9bb8877' + elif network == 'testnet': + bulk_checkout_address = '0x3E2849E2A489C8fE47F52847c42aF2E8A82B9973' + + # Get bulk checkout contract instance + bulk_checkout_contract = w3.eth.contract(address=bulk_checkout_address, abi=bulk_checkout_abi) + # Response that calling function uses to set fields on Contribution. Set the defaults here response = { # We set `passed` to `True` if matching transfer is found for this contribution. The @@ -168,7 +178,7 @@ def grants_transaction_validator(contribution, w3): return response # Check for dropped and replaced txn - tx_hash, status, timestamp = check_for_replaced_tx(tx_hash, network) + tx_hash, status, _ = check_for_replaced_tx(tx_hash, network, chain=chain) # If transaction was successful, continue to validate it if status == 'success': @@ -220,7 +230,7 @@ def grants_transaction_validator(contribution, w3): is_correct_token = event['args']['token'].lower() == expected_token transfer_amount = event['args']['amount'] - is_correct_amount = transfer_amount > expected_amount_min and transfer_amount < expected_amount_max + is_correct_amount = transfer_amount >= expected_amount_min and transfer_amount <= expected_amount_max if is_correct_recipient and is_correct_token and is_correct_amount: # We found the event log corresponding to the contribution parameters diff --git a/app/grants/models.py b/app/grants/models.py index b2ad1236a38..47fcf205d91 100644 --- a/app/grants/models.py +++ b/app/grants/models.py @@ -46,6 +46,7 @@ import requests from django_extensions.db.fields import AutoSlugField from economy.models import SuperModel +from economy.tx import check_for_replaced_tx from economy.utils import ConversionRateNotFoundError, convert_amount from gas.utils import eth_usd_conv_rate, recommend_min_gas_price_to_confirm_in_time from grants.utils import generate_collection_thumbnail, get_upload_filename, is_grant_team_member @@ -1681,6 +1682,7 @@ class Contribution(SuperModel): CHECKOUT_TYPES = [ ('eth_std', 'eth_std'), ('eth_zksync', 'eth_zksync'), + ('eth_polygon', 'eth_polygon'), ('zcash_std', 'zcash_std'), ('celo_std', 'celo_std'), ('zil_std', 'zil_std'), @@ -1767,6 +1769,9 @@ def blockexplorer_url_txid(self): def blockexplorer_url_helper(self, tx_id): if self.checkout_type == 'eth_zksync': return f'https://zkscan.io/explorer/transactions/{tx_id.replace("sync-tx:", "")}' + if self.checkout_type == 'eth_polygon': + network_sub = f"mumbai." if self.subscription and self.subscription.network != 'mainnet' else '' + return f'https://{network_sub}polygonscan.com/tx/{tx_id}' if self.checkout_type == 'eth_std': network_sub = f"{self.subscription.network}." if self.subscription and self.subscription.network != 'mainnet' else '' return f'https://{network_sub}etherscan.io/tx/{tx_id}' @@ -1808,7 +1813,7 @@ def leave_gitcoinbot_comment_for_status(self, status): "comment":comment, "is_edited":True, } - ); + ) except Exception as e: print(e) @@ -1816,9 +1821,8 @@ def leave_gitcoinbot_comment_for_status(self, status): def update_tx_status(self): """Updates tx status for Ethereum contributions.""" try: + from dashboard.utils import get_web3 from economy.tx import grants_transaction_validator - from dashboard.utils import get_tx_status - from economy.tx import getReplacedTX # If `tx_override` is True, we don't run the validator for this contribution if self.tx_override: @@ -1849,20 +1853,19 @@ def update_tx_status(self): self.tx_cleared = True self.validator_comment = "zkSync checkout. Success" if self.success else f"zkSync Checkout. {tx_data['fail_reason']}" - elif self.checkout_type == 'eth_std': - # Standard L1 checkout using the BulkCheckout contract + elif self.checkout_type == 'eth_std' or self.checkout_type == 'eth_polygon': + # Standard L1 and sidechain L2 checkout using the BulkCheckout contract + # get active chain std/polygon + chain = self.checkout_type.split('_')[-1] + # Prepare web3 provider - PROVIDER = "wss://" + network + ".infura.io/ws/v3/" + settings.INFURA_V3_PROJECT_ID - w3 = Web3(Web3.WebsocketProvider(PROVIDER)) + w3 = get_web3(network, chain=chain) # Handle dropped/replaced transactions - split_tx_status, _ = get_tx_status(self.split_tx_id, self.subscription.network, self.created_on) - if split_tx_status in ['pending', 'dropped', 'unknown', '']: - new_tx = getReplacedTX(self.split_tx_id) - if new_tx: - self.split_tx_id = new_tx - split_tx_status, _ = get_tx_status(self.split_tx_id, self.subscription.network, self.created_on) + _, split_tx_status, _ = check_for_replaced_tx( + self.split_tx_id, network, self.created_on, chain=chain + ) # Handle pending txns if split_tx_status in ['pending']: @@ -1888,7 +1891,7 @@ def update_tx_status(self): return # Validate that the token transfers occurred - response = grants_transaction_validator(self, w3) + response = grants_transaction_validator(self, w3, chain=chain) if len(response['originator']): self.originated_address = response['originator'][0] self.validator_passed = response['validation']['passed'] diff --git a/app/grants/templates/grants/cart/eth.html b/app/grants/templates/grants/cart/eth.html index 228e35122bc..22daa5338e1 100644 --- a/app/grants/templates/grants/cart/eth.html +++ b/app/grants/templates/grants/cart/eth.html @@ -157,11 +157,9 @@