-
Notifications
You must be signed in to change notification settings - Fork 16
feat(withdraw): charge the Rhino fee on Ethereum, Tron and Solana #3213
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 all commits
a95a959
a5c2c5b
aa17aac
f157730
d923ac5
a85ccc4
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 |
|---|---|---|
|
|
@@ -17,7 +17,11 @@ | |
| import { chargesApi } from '@/services/charges' | ||
| import type { CreateChargeRequest, TCharge } from '@/services/services.types' | ||
| import { NATIVE_TOKEN_ADDRESS } from '@/utils/token.utils' | ||
| import { isWithdrawFeeDisproportionate, getMinWithdrawUsdForChain } from '@/utils/cross-chain-fee.utils' | ||
| import { | ||
| isWithdrawFeeDisproportionate, | ||
| getMinWithdrawUsdForChain, | ||
| estimateRhinoNetworkFeeUsd, | ||
| } from '@/utils/cross-chain-fee.utils' | ||
| import { isAmountWithinBalance } from '@/utils/balance.utils' | ||
| import { isBelowRhinoMinDeposit, resolveWithdrawAmount } from '@/utils/withdraw.utils' | ||
| import * as peanutInterfaces from '@/interfaces/peanut-sdk-types' | ||
|
|
@@ -724,7 +728,7 @@ | |
| executionInFlightRef.current = false | ||
| setIsSendingTx(false) | ||
| } | ||
| }, [ | ||
| chargeDetails, | ||
| withdrawData, | ||
| amountToWithdraw, | ||
|
|
@@ -808,7 +812,42 @@ | |
|
|
||
| // Get network fee from Rhino preview. Under SDA the fee is a transparent | ||
| // bridge-fee in USD — no slippage distinction. | ||
| const networkFee = useMemo<number>(() => feeUsd ?? 0, [feeUsd]) | ||
| // | ||
| // Peanut no longer sponsors the fee on Ethereum, Tron and Solana, and a | ||
| // quote issued before Rhino enables the charge comes back at zero. The | ||
| // schedule supplies the fee in that window. It is held apart from the | ||
| // quoted fee because only this one is missing from `receiveAmount`: a | ||
| // quoted fee is already deducted there (withdraw quotes are pay mode). | ||
| // Wait for the quote to resolve before pricing — an in-flight or failed | ||
| // quote is not a zero fee, and a number here would reach the heads-up | ||
| // while the row itself shows a spinner or a dash. | ||
| const scheduledFeeUsd = useMemo<number>(() => { | ||
| if (!isCrossChainWithdrawal || !chargeDetails) return 0 | ||
| if (feeUsd === undefined || isCalculating || isFeeEstimationError) return 0 | ||
| if (feeUsd > 0) return 0 | ||
|
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. BLOCKING: Keep an authenticated zero quote authoritative The quote hook already distinguishes undefined/loading/error from a completed numeric quote, but this guard treats only positive values as authoritative. A completed zero is a valid account-bound provider result (and the API sibling has an explicit zero-fee normalization case), so it must not fall through to client-side pricing. Return zero for the resolved quote or gate the schedule on an explicit server-owned rollout state. |
||
| return estimateRhinoNetworkFeeUsd(chargeDetails.chainId, parseFloat(usdAmount)) ?? 0 | ||
|
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. BLOCKING: Do not replace a resolved zero-fee quote with a schedule For a resolved authenticated quote with feeUsd=0 on Ethereum, Tron, or Solana, this call manufactures a fee and receiveAmountAfterFee lowers only the confirmation display. Provisioning and execution still use the quote's feeUsd=0 and payAmount===receiveAmount, so the screen promises less than Rhino will deliver and names a charge Rhino did not quote. Treat a resolved zero as authoritative; enable Rhino before the app rollout, or expose an explicit backend flag instead of inferring enablement from the chain. |
||
| }, [isCrossChainWithdrawal, chargeDetails, feeUsd, isCalculating, isFeeEstimationError, usdAmount]) | ||
|
|
||
| const networkFee = useMemo<number>( | ||
| () => (feeUsd && feeUsd > 0 ? feeUsd : scheduledFeeUsd), | ||
| [feeUsd, scheduledFeeUsd] | ||
| ) | ||
|
|
||
| // What the recipient actually gets. Rhino deducts the fee on delivery, so a | ||
| // scheduled fee has to come off the quoted delivery here — the card must | ||
| // not promise the full amount and name a fee beside it. The quote is | ||
| // denominated in the destination token, so convert with its price (ETH to | ||
| // Ethereum is quoted in ETH, not dollars). | ||
| const receiveAmountAfterFee = useMemo<string | null | undefined>(() => { | ||
| if (!scheduledFeeUsd || !receiveAmount) return receiveAmount | ||
| const tokenPrice = withdrawData?.token.price | ||
| if (!tokenPrice || !Number.isFinite(tokenPrice) || tokenPrice <= 0) return receiveAmount | ||
| const net = parseFloat(receiveAmount) - scheduledFeeUsd / tokenPrice | ||
| if (!Number.isFinite(net) || net <= 0) return receiveAmount | ||
|
abalinda marked this conversation as resolved.
|
||
| // Trim the zeros toFixed pads to the token's precision — the row reads | ||
| // "$48.465", not "$48.465000". | ||
| return net.toFixed(Number(withdrawData?.token.decimals ?? 6)).replace(/\.?0+$/, '') | ||
| }, [scheduledFeeUsd, receiveAmount, withdrawData]) | ||
|
|
||
| // Non-blocking heads-up when the bridge fee is a large share of the amount | ||
| // (flat mainnet gas dominating a small withdraw). The user can still proceed | ||
|
Comment on lines
812
to
853
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. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '1,115p' src/utils/cross-chain-fee.utils.ts
sed -n '790,870p' 'src/app/(mobile-ui)/withdraw/crypto/page.tsx'
sed -n '880,970p' 'src/app/(mobile-ui)/withdraw/crypto/page.tsx'
rg -n -C 3 'estimateRhinoNetworkFeeUsd|feeUsd \?\? 0|zero quote|zero.*fee|sponsored' src/app/'(mobile-ui)'/withdraw/crypto src/utils/cross-chain-fee.utils.ts src/app/'(mobile-ui)'/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsxRepository: peanutprotocol/peanut-ui Length of output: 25044 🏁 Script executed: #!/bin/bash
set -o pipefail
printf '%s\n' '--- focused files ---'
git ls-files | rg '(^|/)(withdraw|rhino|cross-chain|crypto).*|product/networks|ops/rhino' | head -120
printf '%s\n' '--- affected tests ---'
sed -n '360,475p' 'src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx'
printf '%s\n' '--- quote and fee bindings ---'
rg -n -C 4 'feeUsd|receiveAmount|payAmount|Rhino|rhino|withdraw quotes|withdrawal_fee' src --glob '*.{ts,tsx}' | head -260
printf '%s\n' '--- repository docs/contracts ---'
rg -n -C 4 'zero quote|zero fee|sponsor|sponsored|feeUsd|receiveAmount|withdrawal_fee|Rhino' . --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' --glob '!coverage/**' | head -260Repository: peanutprotocol/peanut-ui Length of output: 43678 🤖 get_repo_knowledge executed:
Length of output: 47223 🏁 Script executed: #!/bin/bash
set -o pipefail
printf '%s\n' '--- page Rhino bindings and submission flow ---'
rg -n -C 8 'provisionSdaTransfer|commitBridgeQuote|getBridgeQuote|feeUsd|payAmount|receiveAmount|quoteAmounts|rhino' 'src/app/(mobile-ui)/withdraw/crypto/page.tsx'
printf '%s\n' '--- Rhino service implementations and contracts ---'
sed -n '1,180p' src/services/rhino-sda.ts
sed -n '1,155p' src/services/rhino-bridge.ts
printf '%s\n' '--- generated route schemas around Rhino ---'
sed -n '9810,9925p' src/types/api.generated.ts
printf '%s\n' '--- all relevant callers ---'
rg -n -C 6 'provisionSdaTransfer|commitBridgeQuote|getBridgeQuote|previewSdaTransfer' src --glob '*.{ts,tsx}'Repository: peanutprotocol/peanut-ui Length of output: 50380 Preserve a resolved zero Rhino fee. 🤖 Prompt for AI Agents |
||
|
|
@@ -854,13 +893,21 @@ | |
| // Rhino accepts SDA deposits below the route minimum on-chain but never | ||
| // bridges them — funds strand at the SDA, uncredited. Block the CTA before | ||
| // the user signs. Same-chain USDC transfers have no minimum. | ||
| const belowMinimumMessage = useMemo<string | null>( | ||
| () => | ||
| isCrossChainWithdrawal && isBelowRhinoMinDeposit(payAmount, minDepositLimitUsd) | ||
| ? `The minimum withdrawal to this network is $${minDepositLimitUsd}. Enter a larger amount.` | ||
| : null, | ||
| [isCrossChainWithdrawal, payAmount, minDepositLimitUsd] | ||
| ) | ||
| const belowMinimumMessage = useMemo<string | null>(() => { | ||
| if (!isCrossChainWithdrawal) return null | ||
| if (isBelowRhinoMinDeposit(payAmount, minDepositLimitUsd)) { | ||
| return `The minimum withdrawal to this network is $${minDepositLimitUsd}. Enter a larger amount.` | ||
| } | ||
| // Rhino's route minimum predates the fee, so it can still admit an | ||
| // amount the fee swallows whole — $0.50 to Solana costs $0.50035 to | ||
| // deliver. Block it rather than show a delivery the recipient will | ||
| // never see. | ||
| const amountUsdValue = parseFloat(usdAmount) | ||
| if (scheduledFeeUsd > 0 && Number.isFinite(amountUsdValue) && scheduledFeeUsd >= amountUsdValue) { | ||
|
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: Enforce the new $1 Solana withdrawal minimum Current product truth sets Solana's minimum withdrawal to $1, but this guard only blocks when the scheduled fee consumes the entire amount. For example, $0.51 passes the existing $0.50 route minimum and this check because the $0.500357 fee is slightly smaller, leaving less than one cent to deliver. Add Solana's $1 floor to the chain minimum used before charge creation and cover amounts between $0.50 and $1 in the flow tests. |
||
| return `The network fee to ${withdrawData?.chain.networkName ?? 'this network'} is $${scheduledFeeUsd.toFixed(2)}, which is more than you are withdrawing. Enter a larger amount or pick a cheaper network.` | ||
| } | ||
| return null | ||
| }, [isCrossChainWithdrawal, payAmount, minDepositLimitUsd, scheduledFeeUsd, usdAmount, withdrawData]) | ||
|
|
||
| // Redirect to main withdraw page for amount input. The push must run in an | ||
| // effect — navigating during render is a React violation ("Cannot update | ||
|
|
@@ -903,7 +950,7 @@ | |
| isCrossChain={isCrossChainWithdrawal} | ||
| isCalculating={isCalculating} | ||
| quoteFailed={isFeeEstimationError} | ||
| receiveAmount={receiveAmount} | ||
| receiveAmount={receiveAmountAfterFee} | ||
| payAmount={payAmount} | ||
| showHighFeeWarning={showHighFeeWarning} | ||
| insufficientBalance={insufficientBalance} | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.