From c18c1327d50a04354ed017110e9cd48774c578de Mon Sep 17 00:00:00 2001 From: Matt Solomon Date: Wed, 9 Sep 2020 09:37:24 -0700 Subject: [PATCH 1/5] Checks and validates cart amounts to ensure they are packable --- app/assets/v2/js/cart.js | 217 +++++++++++++++++++++++---------------- 1 file changed, 129 insertions(+), 88 deletions(-) diff --git a/app/assets/v2/js/cart.js b/app/assets/v2/js/cart.js index 1aa6731e55b..8a6626f4f68 100644 --- a/app/assets/v2/js/cart.js +++ b/app/assets/v2/js/cart.js @@ -930,13 +930,8 @@ Vue.component('grants-cart', { }; // Send saveSubscription request - // check `Preserve log` in console settings to inspect these logs more easily const res = await fetch(url, saveSubscriptionParams); - - console.log('Bulk fund POST response', res); const json = await res.json(); - - console.log('Bulk fund POST response, JSON', json); }, /** @@ -1088,8 +1083,8 @@ Vue.component('grants-cart', { // Valid transaction hash, return it return txHash; } - // Otherwise, parse JSON so 'false' becomes a boolean - return JSON.parse(txHash); + // Otherwise, user was not interrupted + return false; }, /** @@ -1339,25 +1334,14 @@ Vue.component('grants-cart', { this.currentTxNumber += 1; console.log(` Generating signature ${i + 1} of ${donationInputs.length}...`); const donationInput = donationInputs[i]; - - // Transfer amounts must be packable to 5-byte long floating-point representations. So - // here we find the closest packable amount - const amount = zksync.utils.closestPackableTransactionAmount(donationInput.amount); - - // Fees must be packable to 2-byte long floating-point representations. Here we find an - // acceptable transaction fee by querying the server, and this will already be packable - const fee = await this.syncProvider.getTransactionFee( - 'Transfer', // transaction type - donationInput.dest, // recipient address - donationInput.name // token name - ); + const { fee, amount } = await this.getZkSyncFeeAndAmount(donationInput); // Now we can generate the signature for this transfer const signedTransfer = await this.syncWallet.signSyncTransfer({ to: donationInput.dest, token: donationInput.name, - amount: amount.sub(fee.totalFee), - fee: fee.totalFee, + amount, + fee, nonce }); @@ -1394,28 +1378,109 @@ Vue.component('grants-cart', { return; }, + /** + * @notice For a given donation in this.donationInputs, returns the packed fee and amount + * @param donation Object, one element from the this.donationInputs array + */ + async getZkSyncFeeAndAmount(donation) { + // Fees must be packable to 2-byte long floating-point representations. Here we find an + // acceptable transaction fee by querying the server, and this will already be packable + const fee = await this.syncProvider.getTransactionFee( + 'Transfer', // transaction type + donation.dest, // recipient address + donation.name // token name + ); + + // Transfer amounts must be packable to 5-byte long floating-point representations. So + // here we find the closest packable amount + const amountBN = ethers.BigNumber.from(donation.amount); + const amount = zksync.utils.closestPackableTransactionAmount(amountBN.sub(fee.totalFee)); + + return { fee: fee.totalFee, amount }; + }, + + /** + * @notice Setup parameters needed for zkSync checkout + */ + async setupZkSync() { + // Configure ethers and zkSync + this.ethersProvider = new ethers.providers.Web3Provider(provider); + this.signer = this.ethersProvider.getSigner(); + this.syncProvider = await zksync.getDefaultProvider(document.web3network, 'HTTP'); + this.numberOfConfirmationsNeeded = await this.syncProvider.getConfirmationsForEthOpAmount(); + + // Set zkSync contract address based on network + this.zkSyncContractAddress = document.web3network === 'mainnet' + ? zkSyncContractAddressMainnet // mainnet + : zkSyncContractAddressRinkeby; // rinkeby + }, + // ==================================== Main functionality ===================================== /** - * @notice Step 1: Initialize app state and login to zkSync + * @notice Triggers appropriate modal when user begins checkout. If user has an interrupted + * cart, they must finish checking out with that before doing another checkout */ - async zkSyncLogin() { + async startZkSyncCheckoutProcess() { try { - this.zkSyncCheckoutStep1Status = 'pending'; + // Make sure user is connected to web3 and setup zkSync this.userAddress = await this.initializeCheckout(); + await this.setupZkSync(); - // Configure ethers and zkSync - this.ethersProvider = new ethers.providers.Web3Provider(provider); - this.signer = this.ethersProvider.getSigner(); - this.syncProvider = await zksync.getDefaultProvider(document.web3network, 'HTTP'); - this.numberOfConfirmationsNeeded = await this.syncProvider.getConfirmationsForEthOpAmount(); + // See if user was previously interrupted during checkout + await this.checkInterruptStatus(); - // Set zkSync contract address based on network - this.zkSyncContractAddress = document.web3network === 'mainnet' - ? zkSyncContractAddressMainnet // mainnet - : zkSyncContractAddressRinkeby; // rinkeby + // Make sure token list is valid + const selectedTokens = Object.keys(this.donationsToGrants); + selectedTokens.forEach((token) => { + if (!this.zkSyncSupportedTokens.includes(token)) { + throw new Error(`${token} is not supported with zkSync checkout. Supported currencies are: ${this.zkSyncSupportedTokens.join(', ')}`); + } + }); + + // Make sure amounts are packable + for (let i = 0; i < this.donationInputs.length; i += 1) { + const donation = this.donationInputs[i]; + let fee; + let amount; + + try { + // This will throw if amounts are not ok + ({ fee, amount } = await this.getZkSyncFeeAndAmount(donation)); + + // Verify returned values are big numbers above zero + if (!fee.gt(ethers.constants.Zero)) + throw new Error('Something went wrong with fee amount'); + if (!amount.gt(ethers.constants.Zero)) + throw new Error('Something went wrong with fee amount'); + + } catch (e) { + console.error(e); + console.log('Corresponding donation:', donation); + console.log('Corresponding fee:', fee); + console.log('Corresponding amount:', amount); + const tokenDetails = this.getTokenByName(donation.grant.grant_donation_currency); + const amount = ethers.utils.formatUnits(donation.amount, tokenDetails.decimals); + + throw new Error(`Amount of ${amount} ${donation.name} is too small for zkSync. This amount comes from either the amounts in your cart minus the contribution to Gitcoin, or is the value of the Gitcoin contribution itself. Please increase either the amounts in your cart or increase the Gitcoin contribution percentage.`); + } + } + + this.showZkSyncModal = true; + } catch (e) { + this.handleError(e); + } + }, + + /** + * @notice Step 1: Initialize app state and login to zkSync + */ + async zkSyncLogin() { + try { + this.zkSyncCheckoutStep1Status = 'pending'; + // Set contract to deposit through based on number of tokens used. We do this to save // gas costs by avoiding the overhead of the batch deposit contract if the user is only // donating one token @@ -1467,41 +1532,6 @@ Vue.component('grants-cart', { } }, - /** - * @notice Executes final shared steps between nominal flow and interrupt flow - * @param receipt receipt from the deposit transaction - */ - async finishZkSyncStep3(receipt) { - // Track number of confirmations live in UI - this.updateConfirmationsInUI(); - - // Wait for deposit to be committed ---------------------------------------------------------- - // Parse logs in receipt so we can get priority request IDs from the events - const serialId = this.getDepositSerialId(receipt); - - // Wait for that ID to be acknowledged by the zkSync network - await this.waitForSerialIdCommitment(serialId); - - // Final steps ------------------------------------------------------------------------------- - // Unlock deterministic wallet's zkSync account - await this.checkAndRegisterSigningKey(); - - // Fetch the expected nonce from the network. We cannot assume it's zero because this may - // not be the user's first checkout - let nonce = await this.getSyncWalletNonce(); - - // Generate signatures - const donationSignatures = await this.generateTransferSignatures(nonce); - - // Dispatch the transfers - await this.dispatchSignedTransfers(donationSignatures); - console.log('✅✅✅ Checkout complete!'); - - // Final processing - await this.setInterruptStatus(null, this.userAddress); - await this.finalizeCheckout(); - }, - /** * @notice Step 3: Main function for executing zkSync checkout */ @@ -1630,27 +1660,38 @@ Vue.component('grants-cart', { }, /** - * @notice Triggers appropriate modal when user begins checkout. If user has an interrupted - * cart, they must finish checking out with that before doing another checkout + * @notice Executes final shared steps between nominal flow and interrupt flow + * @param receipt receipt from the deposit transaction */ - async startZkSyncCheckoutProcess() { - try { - // See if user was previously interrupted during checkout - await this.checkInterruptStatus(); + async finishZkSyncStep3(receipt) { + // Track number of confirmations live in UI + this.updateConfirmationsInUI(); - // Make sure token list is valid - const selectedTokens = Object.keys(this.donationsToGrants); - - selectedTokens.forEach((token) => { - if (!this.zkSyncSupportedTokens.includes(token)) { - throw new Error(`${token} is not supported with zkSync checkout. Supported currencies are: ${this.zkSyncSupportedTokens.join(', ')}`); - } - }); + // Wait for deposit to be committed ---------------------------------------------------------- + // Parse logs in receipt so we can get priority request IDs from the events + const serialId = this.getDepositSerialId(receipt); - this.showZkSyncModal = true; - } catch (e) { - this.handleError(e); - } + // Wait for that ID to be acknowledged by the zkSync network + await this.waitForSerialIdCommitment(serialId); + + // Final steps ------------------------------------------------------------------------------- + // Unlock deterministic wallet's zkSync account + await this.checkAndRegisterSigningKey(); + + // Fetch the expected nonce from the network. We cannot assume it's zero because this may + // not be the user's first checkout + let nonce = await this.getSyncWalletNonce(); + + // Generate signatures + const donationSignatures = await this.generateTransferSignatures(nonce); + + // Dispatch the transfers + await this.dispatchSignedTransfers(donationSignatures); + console.log('✅✅✅ Checkout complete!'); + + // Final processing + await this.setInterruptStatus(null, this.userAddress); + await this.finalizeCheckout(); } @@ -1769,8 +1810,6 @@ Vue.component('grants-cart', { // are generated -- to increase reliability. This is because the beforeunload may sometimes // be ignored by browsers, e.g. if users have not interacted with the page window.addEventListener('beforeunload', (e) => { - console.log('this.zkSyncCheckoutStep1Status: ', this.zkSyncCheckoutStep1Status); - console.log('this.zkSyncCheckoutStep3Status: ', this.zkSyncCheckoutStep3Status); if ( this.zkSyncCheckoutStep3Status === 'pending' && this.zkSyncCheckoutStep3Status !== 'complete' @@ -1787,6 +1826,8 @@ Vue.component('grants-cart', { // See if user was previously interrupted during checkout await this.checkInterruptStatus(); if (this.zkSyncWasInterrupted) { + this.userAddress = (await web3.eth.getAccounts())[0]; + await this.setupZkSync(); this.showZkSyncModal = true; } From ec8851b82e3cdccb170cf0e994df1aaa6698d463 Mon Sep 17 00:00:00 2001 From: Matt Solomon Date: Wed, 9 Sep 2020 13:54:35 -0700 Subject: [PATCH 2/5] Add support for additional deposits. After donations are complete, all leftover balances are sent to user's web3 address --- app/assets/v2/js/cart.js | 130 ++++++++++++++++++++-- app/grants/templates/grants/cart-vue.html | 60 ++++++++++ 2 files changed, 179 insertions(+), 11 deletions(-) diff --git a/app/assets/v2/js/cart.js b/app/assets/v2/js/cart.js index 8a6626f4f68..fecf500eeb2 100644 --- a/app/assets/v2/js/cart.js +++ b/app/assets/v2/js/cart.js @@ -62,6 +62,9 @@ Vue.component('grants-cart', { zkSyncCheckoutFlowStep: 0, // used for UI updates during the final step currentTxNumber: 0, // used as part of the UI updates during the final step zkSyncWasInterrupted: undefined, // read from local storage, true if user closes window before deposit is complete + showAdvancedSettings: false, // advanced settings let user deposit extra funds into zkSync + zkSyncAdditionalDeposits: [], // array of objects of: { amount: ABC, tokenSymbol: 'XYZ' } + zkSyncDonationInputsEthAmount: undefined, // version of donationInputsEthAmount, but used to account for additional deposit amount // SMS validation csrf: $("input[name='csrfmiddlewaretoken']").val(), validationStep: 'intro', @@ -708,6 +711,14 @@ Vue.component('grants-cart', { callbackParams = [] ) { console.log('Requesting token approvals...'); + + if (allowanceData.length === 0) { + console.log('✅ No approvals needed'); + if (callback) + await callback(...callbackParams); + return; + } + indicateMetamaskPopup(); for (let i = 0; i < allowanceData.length; i += 1) { const allowance = allowanceData[i].allowance; @@ -1328,8 +1339,9 @@ Vue.component('grants-cart', { console.log('Generating signatures for transfers...'); console.log(' Array of donations to be made is', donationInputs); - const donationSignatures = []; + const donationSignatures = []; // signatures for grant contribution transfers + // Get signatures for donation transfers for (let i = 0; i < donationInputs.length; i += 1) { this.currentTxNumber += 1; console.log(` Generating signature ${i + 1} of ${donationInputs.length}...`); @@ -1362,6 +1374,8 @@ Vue.component('grants-cart', { async dispatchSignedTransfers(donationSignatures) { console.log('Sending transfers to the network...'); this.zkSyncCheckoutFlowStep += 1; // sending transactions + + // Dispatch donations ------------------------------------------------------------------------ for (let i = 0; i < donationSignatures.length; i += 1) { this.currentTxNumber += 1; console.log(` Sending transfer ${i + 1} of ${donationSignatures.length}...`); @@ -1372,9 +1386,54 @@ Vue.component('grants-cart', { console.log(` ✅ Got transfer ${i + 1} receipt`, receipt); } + + // Transfer any remaining tokens to user's main wallet --------------------------------------- + this.zkSyncCheckoutFlowStep += 1; // Done! + const gitcoinZkSyncState = await this.syncProvider.getState(this.syncWallet.cachedAddress); + const balances = gitcoinZkSyncState.committed.balances; + const tokens = Object.keys(balances); + + // Loop through each token the user has + for (let i = 0; i < tokens.length; i += 1) { + try { + const tokenSymbol = tokens[i]; + const transferInfo = { + dest: this.userAddress, + name: tokenSymbol, + amount: balances[tokenSymbol] + }; + + console.log(`Sending remaining ${tokenSymbol} to user's main zkSync wallet...`); + const { fee, amount } = await this.getZkSyncFeeAndAmount(transferInfo); + + // Send transfer + const tx = await this.syncWallet.syncTransfer({ + to: transferInfo.dest, + token: transferInfo.name, + amount, + fee + }); + + console.log(' Transfer sent', tx); + + // Wait for it to be committed + const receipt = await tx.awaitReceipt(); + + console.log(' ✅ Got transfer receipt', receipt); + } catch (e) { + if (e.message === 'zkSync transaction failed: Not enough balance') { + // Only dust is left for this token, so skip it + console.log(' ❗ Only dust left, skipping this transfer'); + continue; + } + throw e; + } + } + + // Done! this.zkSyncCheckoutStep3Status = 'complete'; this.zkSyncCheckoutFlowStep += 1; // Done! - console.log('✅ Transfers have been successfully sent'); + console.log('✅ All transfers have been successfully sent'); return; }, @@ -1408,6 +1467,7 @@ Vue.component('grants-cart', { this.signer = this.ethersProvider.getSigner(); this.syncProvider = await zksync.getDefaultProvider(document.web3network, 'HTTP'); this.numberOfConfirmationsNeeded = await this.syncProvider.getConfirmationsForEthOpAmount(); + this.zkSyncDonationInputsEthAmount = this.donationInputsEthAmount; // Set zkSync contract address based on network this.zkSyncContractAddress = document.web3network === 'mainnet' @@ -1431,6 +1491,9 @@ Vue.component('grants-cart', { // See if user was previously interrupted during checkout await this.checkInterruptStatus(); + // Set current ETH amount + this.zkSyncDonationInputsEthAmount = this.donationInputsEthAmount; + // Make sure token list is valid const selectedTokens = Object.keys(this.donationsToGrants); @@ -1468,6 +1531,10 @@ Vue.component('grants-cart', { } } + // Populate field for holding additional deposits + this.zkSyncAdditionalDeposits = []; // clear existing data + selectedTokens.forEach((tokenSymbol) => this.zkSyncAdditionalDeposits.push({amount: 0, tokenSymbol })); + this.showZkSyncModal = true; } catch (e) { this.handleError(e); @@ -1493,13 +1560,11 @@ Vue.component('grants-cart', { // Prompt for user's signature to login to zkSync this.syncWallet = await this.loginToZkSync(); - // Check allowances for next step, for better UX on next step. - // This just does tken approvals and balance checks, and does not execute approavals. - // We check against userAddress (the main web3 wallet) because this is where funds will - // be transferred from - this.zkSyncAllowanceData = await this.getAllowanceData(this.userAddress, this.depositContractToUse); - if (this.zkSyncAllowanceData.length === 0) { - // User is only donating ETH, so does not need any token approvals + // Skip next step if only donating ETH, but check that user has enough balance + const selectedTokens = Object.keys(this.donationsToGrants); + + if (selectedTokens.length === 1 && selectedTokens[0] === 'ETH') { + this.zkSyncAllowanceData = await this.getAllowanceData(this.userAddress, this.depositContractToUse); this.zkSyncCheckoutStep2Status = 'not-applicable'; } this.zkSyncCheckoutStep1Status = 'complete'; @@ -1515,7 +1580,50 @@ Vue.component('grants-cart', { async zkSyncApprovals() { try { this.zkSyncCheckoutStep2Status = 'pending'; - + this.zkSyncAllowanceData = await this.getAllowanceData(this.userAddress, this.depositContractToUse); + const BigNumber = ethers.ethers.BigNumber; + + // Add token allowances for any additional deposits that user has specified + for (let i = 0; i < this.zkSyncAllowanceData.length; i += 1) { + const allowanceDetails = this.zkSyncAllowanceData[i]; + const tokenSymbol = allowanceDetails.tokenName; + const decimals = this.getTokenByName(tokenSymbol).decimals; + const currentAmount = BigNumber.from(allowanceDetails.allowance); + const extra = this.zkSyncAdditionalDeposits.filter((x) => x.tokenSymbol === tokenSymbol)[0]; + + if (!extra) + continue; + + const additionalAmount = ethers.utils.parseUnits(String(extra.amount), decimals); + const newAmount = currentAmount.add(additionalAmount).toString(); + + this.zkSyncAllowanceData[i].allowance = newAmount; + + // Make sure user has enough funds + const userTokenBalance = await allowanceDetails.contract.methods + .balanceOf(this.userAddress) + .call({from: this.userAddress}); + + if (BigNumber.from(userTokenBalance).lt(BigNumber.from(newAmount))) + throw new Error(`Insufficient ${tokenSymbol} balance to complete checkout`, 'error'); + } + + // Add ETH additional deposit and ensure user has enough for donation + gas (use lte not lt) + const selectedTokens = Object.keys(this.donationsToGrants); + + if (selectedTokens.includes('ETH') && this.zkSyncAdditionalDeposits.length > 0) { + const initialAmount = BigNumber.from(this.zkSyncDonationInputsEthAmount); + const newAmount = ethers.utils.parseEther( + String(this.zkSyncAdditionalDeposits.filter((x) => x.tokenSymbol === 'ETH')[0].amount) + ); + + this.zkSyncDonationInputsEthAmount = initialAmount.add(newAmount).toString(); + const userEthBalance = await web3.eth.getBalance(this.userAddress); + + if (BigNumber.from(userEthBalance).lte(BigNumber.from(this.zkSyncDonationInputsEthAmount))) + throw new Error('Insufficient ETH balance to complete checkout'); + } + // Otherwise, request approvals. As mentioned above, we check against userAddress // (the main web3 wallet) because this is where funds will be transferred from await this.requestAllowanceApprovalsThenExecuteCallback( @@ -1539,7 +1647,7 @@ Vue.component('grants-cart', { try { // Setup ------------------------------------------------------------------------------------- this.zkSyncCheckoutStep3Status = 'pending'; - const ethAmount = this.donationInputsEthAmount; // amount of ETH being donated + const ethAmount = this.zkSyncDonationInputsEthAmount; // amount of ETH being donated const depositRecipient = this.syncWallet.address(); // address of deposit recipient // Deposit funds --------------------------------------------------------------------------- diff --git a/app/grants/templates/grants/cart-vue.html b/app/grants/templates/grants/cart-vue.html index cd098e7b085..378ec7f036b 100644 --- a/app/grants/templates/grants/cart-vue.html +++ b/app/grants/templates/grants/cart-vue.html @@ -550,6 +550,59 @@


+ {% comment %} Advanced settings {% endcomment %} +
+
+
+ Advanced settings +
+
+
+
+ Save 99% on future checkouts! +
+
+ When you have enough funds on zkSync there is no need for an L1 transaction. + The funds can also be accessed via + https://wallet.zksync.io/ + for use outside of Gitcoin. +
+
+
+ Current deposit +
+
+   +
+
+ Additional deposit +
+
+
+ {% comment %} v-for each token in cart {% endcomment %} +
+
+ [[ donationsTotal[additionalDeposit.tokenSymbol] ]] [[ additionalDeposit.tokenSymbol ]] +
+
+ + +
+
+
+ {% comment %} User must input amount before {% endcomment %} + +
[[ additionalDeposit.tokenSymbol ]]
+
+
+
+
+
+
{% comment %} Body {% endcomment %}
{% comment %} Step 1 {% endcomment %} @@ -662,15 +715,22 @@

Confirmations received: [[currentConfirmationNumber]] of [[numberOfConfirmationsNeeded]]

+ {% comment %} Wait for state change commitment {% endcomment %} Waiting for confirmation that deposit was received by zkSync...
+ {% comment %} Get signatures for each transfer {% endcomment %} Preparing transfer [[currentTxNumber]] of [[donationInputs.length]]
+ {% comment %} Dispatch transfers to the zkSync network {% endcomment %} Sending transfer [[currentTxNumber]] of [[donationInputs.length]]
+ {% comment %} Transfer remaining balances to user's main wallet {% endcomment %} + Finalizing contributions... +
+
Checkout complete!
From 4dd5449613be93f9e1ff726fdb0619885defb76e Mon Sep 17 00:00:00 2001 From: Matt Solomon Date: Wed, 9 Sep 2020 15:25:45 -0700 Subject: [PATCH 3/5] Update appearance of zkSync modal to match new designs --- app/grants/templates/grants/cart-vue.html | 109 ++++++++++++---------- 1 file changed, 60 insertions(+), 49 deletions(-) diff --git a/app/grants/templates/grants/cart-vue.html b/app/grants/templates/grants/cart-vue.html index 378ec7f036b..168e1060c09 100644 --- a/app/grants/templates/grants/cart-vue.html +++ b/app/grants/templates/grants/cart-vue.html @@ -520,33 +520,76 @@

Verify your phone number