Skip to content

Commit

Permalink
feat: send native token max without dust (#390)
Browse files Browse the repository at this point in the history
* Implement sendMax for native tokens

* Correctly handle "max fee per gas less than block base fee" error

* Fix native send max for Optimism

* Specify gasPrice when sending max to stop wallets from rugging us

* Throw error when native balance < toll

* yarn prettier + yarn lint

* Add Chinese translation for error msg

* Apply suggestions from code review

Co-authored-by: Matt Solomon <matt@mattsolomon.dev>

* If the network doesn't support 1559 pricing, base fee is 0 not 1

* Fix failing builds

---------

Co-authored-by: garyghayrat <f255eb47-1441-48a5-be8e-0a9e3193245c@anonaddy.me>
Co-authored-by: Matt Solomon <matt@mattsolomon.dev>
  • Loading branch information
3 people committed Mar 16, 2023
1 parent 56903d8 commit 31a72ae
Show file tree
Hide file tree
Showing 5 changed files with 145 additions and 16 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/ci.yaml
Expand Up @@ -33,6 +33,8 @@ jobs:
INFURA_ID: ${{ secrets.INFURA_ID }}
ETHERSCAN_API_KEY: ${{ secrets.ETHERSCAN_API_KEY }}
POLYGONSCAN_API_KEY: ${{ secrets.POLYGONSCAN_API_KEY }}
OPTIMISTIC_ETHERSCAN_API_KEY: ${{ secrets.OPTIMISTIC_ETHERSCAN_API_KEY }}
ARBISCAN_API_KEY: ${{ secrets.ARBISCAN_API_KEY }}
steps:
- uses: actions/checkout@v3
with:
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/locales/en-US.json
Expand Up @@ -68,6 +68,7 @@
"question-circle-warning": "Don't use this feature unless you know what you're doing.",
"learn-more": "Learn more",
"select-token": "Select token to send",
"slippage-exceeded": "Actual send amount would materially differ from the estimate due to changing gas prices. Please try again.",
"token": "Token",
"amount": "Amount to send",
"summary": "Summary",
Expand All @@ -76,6 +77,7 @@
"fee-explain": "Transactions on %{chainName} are very cheap, so a small fee is charged to deter spamming the protocol.",
"total": "Total",
"max": "Max",
"max-native-less-than-toll": "Cannot send max. Balance less than toll.",
"copy-payment-link": "Copy payment link",
"enter-an-amount": "Please enter an amount",
"enter-a-recipient": "Please enter a recipient",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/locales/zh-CN.json
Expand Up @@ -68,6 +68,7 @@
"question-circle-warning": "除非您知道自己在做什么,不要使用此功能。",
"learn-more": "了解更多",
"select-token": "选择要发送的代币",
"slippage-exceeded": "由于费用价格的变化,实际发送量将与估计值有很大差异。请重试。",
"token": "代币",
"amount": "汇款金额",
"summary": "总结",
Expand All @@ -76,6 +77,7 @@
"fee-explain": "在 %{chainName} 上交易非常便宜,因此会收取少量费用以阻止垃圾信息。",
"total": "总额",
"max": "最大限额",
"max-native-less-than-toll": "无法发送最大限额。费用超过钱包金额。",
"copy-payment-link": "复制付款链接",
"enter-an-amount": "请输入金额",
"enter-a-recipient": "请输入收款人",
Expand Down
110 changes: 99 additions & 11 deletions frontend/src/pages/AccountSend.vue
Expand Up @@ -149,8 +149,9 @@
:disable="isSending"
placeholder="0"
:appendButtonDisable="!recipientId || !isValidRecipientId"
:appendButtonLabel="token && NATIVE_TOKEN && token.address !== NATIVE_TOKEN.address ? $t('Send.max') : ''"
:appendButtonLabel="$t('Send.max')"
@click="setHumanAmountMax"
@input="() => (sendMax = false)"
lazy-rules
:rules="isValidTokenAmount"
ref="humanAmountBaseInputRef"
Expand Down Expand Up @@ -248,7 +249,7 @@
// --- External imports ---
import { computed, defineComponent, onMounted, ref, watch } from 'vue';
import { QForm, QInput, QSelect } from 'quasar';
import { utils as umbraUtils } from '@umbracash/umbra-js';
import { RandomNumber, utils as umbraUtils } from '@umbracash/umbra-js';
// --- Components ---
import BaseInput from 'components/BaseInput.vue';
import BaseSelect from 'components/BaseSelect.vue';
Expand Down Expand Up @@ -312,6 +313,7 @@ function useSendForm() {
const isValidForm = ref(false);
const isValidRecipientId = ref(true); // for showing/hiding bottom space (error message div) under input field
const toll = ref<BigNumber>(Zero);
const sendMax = ref(false);
// Computed form parameters.
const showAdvancedWarning = computed(() => advancedAcknowledged.value === false && useNormalPubKey.value === true);
Expand Down Expand Up @@ -365,6 +367,12 @@ function useSendForm() {
advancedAcknowledged.value = false;
}
// Switch off the sendMax flag and clear value if we change tokens.
if (tokenValue !== (_prevTokenValue || prevNativeTokenValue) && sendMax.value) {
sendMax.value = false;
humanAmount.value = '0.0';
}
// Perform minimal required validation based on what changed.
if (useNormalPubKey !== prevUseNormalPubKey || recipientIdValue !== prevRecipientIdValue) {
const recipientIdInputRef = recipientIdBaseInputRef.value?.$refs.QInput as QInput;
Expand Down Expand Up @@ -469,16 +477,56 @@ function useSendForm() {
// This does not account for gas fees, but this gets us close enough and we delegate that to the wallet
await getTokenBalances();
const { address: tokenAddress, decimals } = token.value;
const tokenAmount = parseUnits(humanAmount.value, decimals);
const currentBalance = balances.value[tokenAddress];
const sendingNativeToken = tokenAddress === NATIVE_TOKEN.value.address;
let sendMaxGasPrice; // Used if sendMax is true.
let sendMaxGasLimit; // Used if sendMax is true.
let tokenAmount = parseUnits(humanAmount.value, decimals);
// Refresh the tokenAmount if the sendMax flag is set.
if (sendMax.value) {
if (sendingNativeToken) {
const [_toAddress, _estimatedNativeSendGasLimit] = await Promise.all([
toAddress(recipientId.value, provider.value!),
estimateNativeSendGasLimit(),
]);
// Get current balance less gas costs.
const {
gasPrice: _sendMaxGasPrice,
gasLimit: _sendMaxGasLimit,
ethToSend: balanceLessGasCosts,
} = await umbraUtils.getEthSweepGasInfo(userAddress.value!, _toAddress, provider.value!, {
// We override the gasLimit here because we are sending to an
// address that has never been seen before, which increases gas
// costs and is not accounted for by getEthSweepGasInfo.
gasLimit: _estimatedNativeSendGasLimit,
});
sendMaxGasPrice = _sendMaxGasPrice;
sendMaxGasLimit = _sendMaxGasLimit;
tokenAmount = balanceLessGasCosts.sub(toll.value);
} else {
tokenAmount = currentBalance;
}
}
if (tokenAddress === NATIVE_TOKEN.value.address) {
// Throw if the tokenAmount differs from humanAmount.value by too much.
const expectedAmount = parseUnits(humanAmount.value, decimals);
// Only 2% downward slippage is tolerated. Any more and we throw.
if (tokenAmount.mul('100').div(expectedAmount).lt('98')) {
throw new Error(`${tc('Send.slippage-exceeded')}`);
}
// Sending the native token, so check that user has balance of: amount being sent + toll
const requiredAmount = tokenAmount.add(toll.value);
if (requiredAmount.gt(balances.value[tokenAddress])) throw new Error(`${tc('Send.amount-exceeds-balance')}`);
if (requiredAmount.gt(currentBalance)) {
throw new Error(`${tc('Send.amount-exceeds-balance')}`);
}
} else {
// Sending other tokens, so we need to check both separately
const nativeTokenErrorMsg = `${NATIVE_TOKEN.value.symbol} ${tc('Send.umbra-fee-exceeds-balance')}`;
if (toll.value.gt(balances.value[NATIVE_TOKEN.value.address])) throw new Error(nativeTokenErrorMsg);
if (tokenAmount.gt(balances.value[tokenAddress])) throw new Error(tc('Send.amount-exceeds-balance'));
if (tokenAmount.gt(currentBalance)) throw new Error(tc('Send.amount-exceeds-balance'));
}
// If token, get approval when required
Expand All @@ -499,6 +547,20 @@ function useSendForm() {
// Send with Umbra
const { tx } = await umbra.value.send(signer.value, tokenAddress, tokenAmount, recipientId.value, {
advanced: shouldUseNormalPubKey.value,
// When attempting to sendMax, we override the gasPrice to use the price
// from the sweepETH function that estimated the tokenAmount. That function
// calculates the tokenAmount based on a low (but reasonable) estimate of
// the gasPrice. Wallets are configured to choose a high-probability
// gasPrice for new transactions -- which is to say: a fairly high gasPrice.
// So if we don't specify the gasPrice here, the wallet will almost
// certainly choose a higher gasPrice. And since:
// token amount = (account balance) - (expected gas costs)
// when the wallet increases gas costs on us, we end up in a situation where:
// account balance < (token amount) + (wallet-chosen gas costs)
// So the wallet will reject the transaction on grounds that the account
// doesn't have enough funds.
gasPrice: sendMax.value && sendingNativeToken ? sendMaxGasPrice : undefined,
gasLimit: sendMax.value && sendingNativeToken ? sendMaxGasLimit : undefined,
});
void txNotify(tx.hash, ethersProvider);
await tx.wait();
Expand All @@ -520,18 +582,43 @@ function useSendForm() {
if (!token.value?.address) throw new Error(tc('Send.select-a-token'));
if (!recipientId.value) throw new Error(tc('Send.enter-a-recipient'));
sendMax.value = true;
if (NATIVE_TOKEN.value?.address === token.value?.address) {
if (!userAddress.value || !provider.value) throw new Error(tc('Send.wallet-not-connected'));
const fromAddress = userAddress.value;
const recipientAddress = await toAddress(recipientId.value, provider.value);
const { ethToSend } = await umbraUtils.getEthSweepGasInfo(fromAddress, recipientAddress, provider.value);
humanAmount.value = formatUnits(ethToSend, token.value.decimals);
return ethToSend;
const { ethToSend } = await umbraUtils.getEthSweepGasInfo(fromAddress, recipientAddress, provider.value, {
// We override the gasLimit here because we are sending to an
// address that has never been seen before, which increases gas
// costs and is not accounted for by getEthSweepGasInfo.
gasLimit: await estimateNativeSendGasLimit(),
});
const sendAmount = ethToSend.sub(toll.value);
if (sendAmount.lt('0')) throw new Error(tc('Send.max-native-less-than-toll'));
humanAmount.value = formatUnits(sendAmount, token.value.decimals);
} else {
const tokenBalance = balances.value[token.value.address];
humanAmount.value = formatUnits(tokenBalance.toString(), token.value.decimals);
}
}
const tokenBalance = balances.value[token.value.address];
humanAmount.value = formatUnits(tokenBalance.toString(), token.value.decimals);
return tokenBalance.toString();
// Get an accurate estimate of the amount of gas needed to perform a native send.
async function estimateNativeSendGasLimit() {
return await umbra.value!.umbraContract.estimateGas.sendEth(
// We will be sending to an address that has never been seen before which substantially
// increases gas costs on some networks (e.g. by 25k on mainnet). To ensure this cost is
// included in our gas limit estimate, we estimate using a `to` address that is randomly
// generated (and thus likely to have never been seen before).
new RandomNumber().asHex.replace(/0/g, 'f').replace(/^./, '0').slice(0, 42),
// The toll needs to be correct, otherwise the tx would revert.
toll.value,
// Fake values just to get a reasonable estimate.
new RandomNumber().asHex.replace(/0/g, 'f').replace(/^./, '0'), // pubKeyXCoordinate
new RandomNumber().asHex.replace(/0/g, 'f').replace(/^./, '0'), // ciphertext
// Value doesn't matter, it just needs to be more than the toll else the tx would revert.
{ value: toll.value.add('1') }
);
}
return {
Expand All @@ -556,6 +643,7 @@ function useSendForm() {
recipientIdBaseInputRef,
sendAdvancedButton,
sendFormRef,
sendMax,
setHumanAmountMax,
showAdvancedSendWarning,
showAdvancedWarning,
Expand Down
45 changes: 40 additions & 5 deletions umbra-js/src/utils/utils.ts
Expand Up @@ -360,10 +360,16 @@ export async function getEthSweepGasInfo(
const gasLimitOf21k = [1, 4, 5, 10, 137, 1337]; // networks where ETH sends cost 21000 gas
const ignoreGasPriceOverride = [10, 42161]; // to maximize ETH sweeps, ignore uer-specified gasPrice overrides

const [toAddressCode, network, fromBalance, providerGasPrice] = await Promise.all([
const [toAddressCode, network, fromBalance, lastBlockData, providerGasPrice] = await Promise.all([
provider.getCode(to),
provider.getNetwork(),
provider.getBalance(from),
provider.getBlock('latest'),
// We use `getGasPrice` instead of `getFeeData` because:
// (a) getGasPrice returns low estimates whereas getFeeData intentionally returns high estimates
// of gas costs, since the latter presumes the post-1559 pricing model in which extra fees
// are refunded; we don't want there to be any refunds since we're trying to sweep the account;
// (b) not all chains support getFeeData and/or 1559 gas pricing, e.g. Avalanche and Optimism.
provider.getGasPrice(),
]);
const isEoa = toAddressCode === '0x';
Expand All @@ -378,22 +384,51 @@ export async function getEthSweepGasInfo(
: await provider.estimateGas({ gasPrice: 0, to, from, value: fromBalance });

// Estimate the gas price, defaulting to the given one unless on a network where we want to use provider gas price
const gasPrice = ignoreGasPriceOverride.includes(chainId)
let gasPrice = ignoreGasPriceOverride.includes(chainId)
? providerGasPrice
: BigNumber.from((await overrides.gasPrice) || providerGasPrice);

// On networks with EIP-1559 gas pricing, the provider will throw an error and refuse to submit
// the tx if the gas price is less than the block-specified base fee. The error is "max fee
// per gas less than block base fee". So we need to ensure that the low estimate we're using isn't
// *too* low. Additionally, if the previous block exceeded the target size, the base fee will be
// increased by 12.5% for the next block, per:
// https://ethereum.org/en/developers/docs/gas/#base-fee
// To be conservative, therefore, we assume a 12.5% increase will affect the base fee for the
// transaction we're about to send.
const baseGasFee = (lastBlockData?.baseFeePerGas || BigNumber.from('0')).mul('1125').div('1000');
if (gasPrice.lt(baseGasFee)) gasPrice = baseGasFee;

// For networks with a lot of gas market volatility, we bump the gas price to
// give us a bit of wiggle room.
let gasPriceScaleFactor;
switch (chainId) {
case 42161:
gasPriceScaleFactor = '110';
break;
default:
gasPriceScaleFactor = '105';
}
gasPrice = gasPrice.mul(gasPriceScaleFactor).div('100');

let txCost = gasPrice.mul(gasLimit);

// On Optimism, we ask the gas price oracle for the L1 data fee that we should add on top of the L2 execution
// cost: https://community.optimism.io/docs/developers/build/transaction-fees/
// For Arbitrum, this is baked into the gasPrice returned from the provider.
let txCost = gasPrice.mul(gasLimit);
if (chainId === 10) {
const nonce = await provider.getTransactionCount(from);
const gasOracleAbi = ['function getL1Fee(bytes memory _data) public view returns (uint256)'];
const gasPriceOracle = new Contract('0x420000000000000000000000000000000000000F', gasOracleAbi, provider);
const l1FeeInWei = await gasPriceOracle.getL1Fee(
let l1FeeInWei = await gasPriceOracle.getL1Fee(
serializeTransaction({ to, value: fromBalance, data: '0x', gasLimit, gasPrice, nonce })
);
txCost = txCost.add(<BigNumber>l1FeeInWei);

// We apply a 70% multiplier to the Optimism oracle quote, since it's frequently low
// by 45+% compared to actual L1 send fees.
l1FeeInWei = (l1FeeInWei as BigNumber).mul('170').div('100');

txCost = txCost.add(l1FeeInWei as BigNumber);
}

// Return the gas price, gas limit, and the transaction cost
Expand Down

0 comments on commit 31a72ae

Please sign in to comment.