-
Notifications
You must be signed in to change notification settings - Fork 14
feat(withdraw): tap the balance to withdraw everything #2842
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 12 commits
b984cc9
09187b2
ca67be6
e959d37
29cd908
b2cc434
149bae3
6de65ff
b2409d1
80c0b05
acc2b39
c92839b
75de393
db33891
271f163
d7c82ce
773b4ca
d828f61
c275e19
6dd1ba4
7361786
280fe99
c786437
24518c7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,7 +20,7 @@ import type { | |
| import { NATIVE_TOKEN_ADDRESS } from '@/utils/token.utils' | ||
| import { isWithdrawFeeDisproportionate, getMinWithdrawUsdForChain } from '@/utils/cross-chain-fee.utils' | ||
| import { isAmountWithinBalance } from '@/utils/balance.utils' | ||
| import { isBelowRhinoMinDeposit } from '@/utils/withdraw.utils' | ||
| import { isBelowRhinoMinDeposit, resolveWithdrawAmount } from '@/utils/withdraw.utils' | ||
| import * as peanutInterfaces from '@/interfaces/peanut-sdk-types' | ||
| import { useRouter } from 'next/navigation' | ||
| import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' | ||
|
|
@@ -64,6 +64,7 @@ export default function WithdrawCryptoPage() { | |
| const { resetTokenContextProvider } = useContext(tokenSelectorContext) | ||
| const { | ||
| amountToWithdraw, | ||
| isMaxWithdrawal, | ||
| usdAmount, | ||
| currentView, | ||
| setCurrentView, | ||
|
|
@@ -148,6 +149,14 @@ export default function WithdrawCryptoPage() { | |
| resetPaymentRecorder() | ||
| }, [setChargeDetails, setTransactionHash, setPaymentDetails, resetRouteCalculation, resetPaymentRecorder]) | ||
|
|
||
| // What the withdrawal actually moves: the amount on screen, plus the | ||
| // sub-cent remainder when the user tapped "use full balance" and did not | ||
| // edit it. See resolveWithdrawAmount for the guard rails (TASK-21899). | ||
| const effectiveAmount = useMemo( | ||
| () => resolveWithdrawAmount(amountToWithdraw, spendableBalance, isMaxWithdrawal, PEANUT_WALLET_TOKEN_DECIMALS), | ||
| [amountToWithdraw, spendableBalance, isMaxWithdrawal] | ||
| ) | ||
|
|
||
| // clear errors when amount changes | ||
| useEffect(() => { | ||
| if (amountToWithdraw) { | ||
|
|
@@ -172,9 +181,9 @@ export default function WithdrawCryptoPage() { | |
| address: address as Address, | ||
| tokenAddress: PEANUT_WALLET_TOKEN as Address, | ||
| chainId: PEANUT_WALLET_CHAIN.id.toString(), | ||
| // amountToWithdraw is USD-denominated; source token is USDC (1:1). | ||
| // effectiveAmount is USD-denominated; source token is USDC (1:1). | ||
| // Required for the bridge path's 'pay' mode (cross-chain ETH/etc). | ||
| tokenAmount: amountToWithdraw, | ||
| tokenAmount: effectiveAmount, | ||
|
abalinda marked this conversation as resolved.
Outdated
|
||
| }, | ||
| destination: { | ||
| recipientAddress: chargeDetails.requestLink.recipientAddress as Address, | ||
|
|
@@ -190,7 +199,7 @@ export default function WithdrawCryptoPage() { | |
| skipGasEstimate: true, // peanut wallet handles gas | ||
| }) | ||
| } | ||
| }, [currentView, chargeDetails, withdrawData, calculateRoute, address, amountToWithdraw]) | ||
| }, [currentView, chargeDetails, withdrawData, calculateRoute, address, effectiveAmount]) | ||
|
|
||
| const handleSetupReview = useCallback( | ||
| async (data: Omit<WithdrawData, 'amount'>) => { | ||
|
|
@@ -209,7 +218,7 @@ export default function WithdrawCryptoPage() { | |
| data.chain.chainId.toString() === PEANUT_WALLET_CHAIN.id.toString() && | ||
| data.token.address.toLowerCase() === PEANUT_WALLET_TOKEN.toLowerCase() | ||
| if (!isSameChainUsdc) { | ||
| const usdToWithdraw = parseFloat(amountToWithdraw) | ||
| const usdToWithdraw = parseFloat(effectiveAmount) | ||
| const minUsd = getMinWithdrawUsdForChain(data.chain.chainId) | ||
| if (!Number.isFinite(usdToWithdraw) || usdToWithdraw < minUsd) { | ||
| const minDisplay = minUsd % 1 === 0 ? `$${minUsd}` : `$${minUsd.toFixed(2)}` | ||
|
|
@@ -230,10 +239,10 @@ export default function WithdrawCryptoPage() { | |
| // units before persisting the request/charge — otherwise meta | ||
| // ends up with `tokenAmount: "1"` + `tokenSymbol: "ETH"` and | ||
| // history renders "1 ETH" for what was actually a $1 withdraw. | ||
| const usdValue = parseFloat(amountToWithdraw) | ||
| const usdValue = parseFloat(effectiveAmount) | ||
|
abalinda marked this conversation as resolved.
Outdated
|
||
| const tokenPrice = data.token.price ?? 0 | ||
| const destinationTokenAmount = | ||
| tokenPrice > 0 ? (usdValue / tokenPrice).toFixed(Number(data.token.decimals)) : amountToWithdraw | ||
| tokenPrice > 0 ? (usdValue / tokenPrice).toFixed(Number(data.token.decimals)) : effectiveAmount | ||
|
|
||
| const completeWithdrawData = { ...data, amount: destinationTokenAmount } | ||
| setWithdrawData(completeWithdrawData) | ||
|
|
@@ -299,6 +308,7 @@ export default function WithdrawCryptoPage() { | |
| }, | ||
| [ | ||
| amountToWithdraw, | ||
| effectiveAmount, | ||
| clearErrors, | ||
| setChargeDetails, | ||
| setIsPreparingReview, | ||
|
|
@@ -385,7 +395,7 @@ export default function WithdrawCryptoPage() { | |
| txHash, | ||
| receipt: r, | ||
| strategy: s, | ||
| } = await sendMoney(withdrawData.address as Address, amountToWithdraw, { | ||
| } = await sendMoney(withdrawData.address as Address, effectiveAmount, { | ||
| kind: 'CRYPTO_WITHDRAW', | ||
| // Lets the backend settle the charge directly when the spend | ||
| // routes through Rain card collateral (collateral-only): the | ||
|
|
@@ -527,6 +537,7 @@ export default function WithdrawCryptoPage() { | |
| chargeDetails, | ||
| withdrawData, | ||
| amountToWithdraw, | ||
| effectiveAmount, | ||
| address, | ||
| transactions, | ||
| payAmount, | ||
|
|
@@ -580,21 +591,19 @@ export default function WithdrawCryptoPage() { | |
| [isCrossChainWithdrawal, networkFee, usdAmount] | ||
| ) | ||
|
|
||
| // Pre-sign affordability gate for cross-chain. The input-time gate only | ||
| // checked the principal, but the kernel must spend principal + bridge fee | ||
| // (`payAmount`), so a withdraw that fit the balance at input can fall short | ||
| // here once the fee is known — and the send would surface the misleading | ||
| // "balance isn't fully available yet" (settling) error instead of an honest | ||
| // "not enough balance". Block it here with the right message. Only once the | ||
| // quote has resolved `payAmount` (skipped while calculating; CTA is disabled | ||
| // by isCalculating anyway). | ||
| const insufficientForFee = useMemo<boolean>( | ||
| // Pre-sign affordability gate on every path: the kernel spend (`payAmount` | ||
| // — the quote's pay side cross-chain, the principal same-chain) must fit | ||
| // the LIVE balance. The input-time gate saw the balance at input; a card | ||
| // spend settling, another withdrawal landing first, or a quoted fee can | ||
| // leave it short here — and the send would surface the misleading | ||
| // "balance isn't fully available yet" (settling) error instead of an | ||
| // honest "not enough balance". Only once the route has resolved | ||
| // `payAmount` (skipped while calculating; CTA is disabled by isCalculating | ||
| // anyway). | ||
| const insufficientBalance = useMemo<boolean>( | ||
| () => | ||
| isCrossChainWithdrawal && | ||
| payAmount != null && | ||
| spendableBalance !== undefined && | ||
| !isAmountWithinBalance(payAmount, spendableBalance), | ||
| [isCrossChainWithdrawal, payAmount, spendableBalance] | ||
| payAmount != null && spendableBalance !== undefined && !isAmountWithinBalance(payAmount, spendableBalance), | ||
| [payAmount, spendableBalance] | ||
| ) | ||
|
|
||
| // Rhino accepts SDA deposits below the route minimum on-chain but never | ||
|
|
@@ -651,7 +660,7 @@ export default function WithdrawCryptoPage() { | |
| receiveAmount={receiveAmount} | ||
| payAmount={payAmount} | ||
| showHighFeeWarning={showHighFeeWarning} | ||
| insufficientBalance={insufficientForFee} | ||
| insufficientBalance={insufficientBalance} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MAJOR: Apply the live-balance gate on Retry This prop gates the normal Confirm CTA, but the real |
||
| belowMinimumMessage={belowMinimumMessage} | ||
| isFromSendFlow={isFromSendFlow} | ||
| /> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BLOCKING: Bind the send amount to the prepared charge
The charge is created from the current
effectiveAmount, but this memo keeps deriving that amount from the live balance through Confirm andsendMoneyuses the new value. For example, prepare a max withdrawal at 10.126123 USDC, then let the balance fall to 10.121111 before Confirm; both floor to the displayed 10.12, so the resolver and gate accept 10.121111 while the request/charge still requires 10.126123. Funds move, then the API validator rejects the underpayment; on the trusted collateral path it instead completes and books the old requested amount. Freeze the exact spend once the charge is prepared, or rebuild the charge and route whenever that amount changes, and cover same-cent balance increases and decreases between Review and Confirm.