From 120b654afe0d440dc77e7eb4f6ff3c8682c0112e Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Thu, 30 May 2024 05:07:16 -0700 Subject: [PATCH] Design tweaks to swap/auction form (#1168) * Make asset/balance selectors grow out of their buttons * Redesign BalanceValueView and add a tooltip * Tweak layout of balance/asset selectors * Use single-row layout * Reorganize swap form * Animate the swap UI more * Support motion in EduInfoCard * Support motion in InputBlock * Tighten up syntax * Support motion in Card and GradientHeader * Use motion throughout Swap page * Fix layout issues in AuctionList * Calculate estimated outputs for an auction * Refactor to use new helper * Add changeset * Undo changes to amount types * Tweak variable name * Simplify code * Fix bug with loading property * Remove unneeded loading prop * Add cmoment * review updates * Box component css tweaks --------- Co-authored-by: Gabe Rodriguez --- .changeset/hip-crabs-tell.md | 7 ++ .../components/shared/balance-selector.tsx | 117 ++++++++++-------- .../shared/edu-panels/edu-info-card.tsx | 10 +- .../src/components/shared/input-block.tsx | 26 ++-- .../staking-actions/form-dialog.tsx | 2 +- .../components/swap/auction-list/index.tsx | 12 +- apps/minifront/src/components/swap/layout.tsx | 21 ++-- .../swap/swap-form/estimate-button.tsx | 40 ++++++ .../src/components/swap/swap-form/index.tsx | 53 ++++---- .../src/components/swap/swap-form/output.tsx | 58 --------- .../output/estimated-output-explanation.tsx | 32 +++++ .../swap/swap-form/output/index.tsx | 84 +++++++++++++ .../swap/swap-form/simulate-swap-button.tsx | 50 -------- .../swap-form/simulate-swap-result/index.tsx | 43 ++++--- .../swap/swap-form/simulate-swap.tsx | 26 ++++ .../swap/swap-form/token-swap-input.tsx | 78 ++++-------- .../src/components/swap/swap-info-card.tsx | 2 +- .../src/components/swap/unclaimed-swaps.tsx | 4 +- .../state/swap/dutch-auction/index.test.ts | 80 ++++++++++++ .../src/state/swap/dutch-auction/index.ts | 53 ++++++++ apps/minifront/src/state/swap/helpers.ts | 37 ++++++ apps/minifront/src/state/swap/instant-swap.ts | 25 +--- packages/getters/src/value-view.ts | 11 +- .../ui/components/ui/balance-value-view.tsx | 29 ++++- packages/ui/components/ui/box.tsx | 57 +++++++-- packages/ui/components/ui/card.tsx | 10 +- packages/ui/components/ui/dialog.tsx | 9 +- packages/ui/components/ui/gradient-header.tsx | 19 ++- 28 files changed, 663 insertions(+), 332 deletions(-) create mode 100644 .changeset/hip-crabs-tell.md create mode 100644 apps/minifront/src/components/swap/swap-form/estimate-button.tsx delete mode 100644 apps/minifront/src/components/swap/swap-form/output.tsx create mode 100644 apps/minifront/src/components/swap/swap-form/output/estimated-output-explanation.tsx create mode 100644 apps/minifront/src/components/swap/swap-form/output/index.tsx delete mode 100644 apps/minifront/src/components/swap/swap-form/simulate-swap-button.tsx create mode 100644 apps/minifront/src/components/swap/swap-form/simulate-swap.tsx create mode 100644 apps/minifront/src/state/swap/dutch-auction/index.test.ts create mode 100644 apps/minifront/src/state/swap/helpers.ts diff --git a/.changeset/hip-crabs-tell.md b/.changeset/hip-crabs-tell.md new file mode 100644 index 0000000000..f90fd9eec4 --- /dev/null +++ b/.changeset/hip-crabs-tell.md @@ -0,0 +1,7 @@ +--- +'@penumbra-zone/getters': minor +'minifront': minor +'@penumbra-zone/ui': minor +--- + +Support estimates of outputs for auctions; redesign the estimate results part of the swap/auction UI diff --git a/apps/minifront/src/components/shared/balance-selector.tsx b/apps/minifront/src/components/shared/balance-selector.tsx index e91bcb88fe..a3c41a5743 100644 --- a/apps/minifront/src/components/shared/balance-selector.tsx +++ b/apps/minifront/src/components/shared/balance-selector.tsx @@ -1,12 +1,11 @@ import { MagnifyingGlassIcon } from '@radix-ui/react-icons'; -import { useState } from 'react'; +import { useId, useState } from 'react'; import { IconInput } from '@penumbra-zone/ui/components/ui/icon-input'; import { Dialog, DialogClose, DialogContent, DialogHeader, - DialogTrigger, } from '@penumbra-zone/ui/components/ui/dialog'; import { cn } from '@penumbra-zone/ui/lib/utils'; import { ValueViewComponent } from '@penumbra-zone/ui/components/ui/tx/view/value'; @@ -14,6 +13,7 @@ import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumb import { getAddressIndex } from '@penumbra-zone/getters/address-view'; import { getDisplayDenomFromView, getSymbolFromValueView } from '@penumbra-zone/getters/value-view'; import { Box } from '@penumbra-zone/ui/components/ui/box'; +import { motion } from 'framer-motion'; const bySearch = (search: string) => (balancesResponse: BalancesResponse) => getDisplayDenomFromView(balancesResponse.balanceView) @@ -39,60 +39,79 @@ interface BalanceSelectorProps { */ export default function BalanceSelector({ value, balances, onChange }: BalanceSelectorProps) { const [search, setSearch] = useState(''); + const [isOpen, setIsOpen] = useState(false); const filteredBalances = search ? balances.filter(bySearch(search)) : balances; + const layoutId = useId(); return ( - - -
+ <> + {!isOpen && ( + setIsOpen(true)} + > -
-
- -
- Select asset -
- - } - value={search} - onChange={setSearch} - placeholder='Search assets...' - /> - -
-

Account

-

Asset

-
-
- {filteredBalances.map((b, i) => { - const index = getAddressIndex(b.accountAddress).account; + + )} + + {isOpen && ( + <> + {/* 0-opacity placeholder for layout's sake */} +
+ +
+ + )} + + + +
+ Select asset +
+ + } + value={search} + onChange={setSearch} + placeholder='Search assets...' + /> + +
+

Account

+

Asset

+
+
+ {filteredBalances.map((b, i) => { + const index = getAddressIndex(b.accountAddress).account; - return ( -
- -
onChange(b)} - > -

{index}

-
- + return ( +
+ +
onChange(b)} + > +

{index}

+
+ +
-
- -
- ); - })} + +
+ ); + })} +
-
-
-
+ +
+ ); } diff --git a/apps/minifront/src/components/shared/edu-panels/edu-info-card.tsx b/apps/minifront/src/components/shared/edu-panels/edu-info-card.tsx index 6616665029..74204548fc 100644 --- a/apps/minifront/src/components/shared/edu-panels/edu-info-card.tsx +++ b/apps/minifront/src/components/shared/edu-panels/edu-info-card.tsx @@ -2,21 +2,23 @@ import { Card } from '@penumbra-zone/ui/components/ui/card'; import { cn } from '@penumbra-zone/ui/lib/utils'; import { GradientHeader } from '@penumbra-zone/ui/components/ui/gradient-header'; import { EduPanel, eduPanelContent } from './content'; +import { motion } from 'framer-motion'; interface HelperCardProps { src: string; label: string; className?: string; content: EduPanel; + layout?: boolean; } -export const EduInfoCard = ({ src, label, className, content }: HelperCardProps) => { +export const EduInfoCard = ({ src, label, className, content, layout }: HelperCardProps) => { return ( - -
+ + icons {label} -
+

{eduPanelContent[content]}

); diff --git a/apps/minifront/src/components/shared/input-block.tsx b/apps/minifront/src/components/shared/input-block.tsx index 1ce5b29958..2061f7add5 100644 --- a/apps/minifront/src/components/shared/input-block.tsx +++ b/apps/minifront/src/components/shared/input-block.tsx @@ -9,13 +9,30 @@ interface InputBlockProps { validations?: Validation[] | undefined; value?: unknown; children: ReactNode; + layout?: boolean; + layoutId?: string; } -export const InputBlock = ({ label, className, validations, value, children }: InputBlockProps) => { +export const InputBlock = ({ + label, + className, + validations, + value, + children, + layout, + layoutId, +}: InputBlockProps) => { const vResult = typeof value === 'string' ? validationResult(value, validations) : undefined; return ( - + {vResult.issue} : null + } + layout={layout} + layoutId={layoutId} + >
-
-

{label}

- {vResult ?
{vResult.issue}
: null} -
- {children}
diff --git a/apps/minifront/src/components/staking/account/delegation-value-view/staking-actions/form-dialog.tsx b/apps/minifront/src/components/staking/account/delegation-value-view/staking-actions/form-dialog.tsx index 3bb336ed2b..6c36dee285 100644 --- a/apps/minifront/src/components/staking/account/delegation-value-view/staking-actions/form-dialog.tsx +++ b/apps/minifront/src/components/staking/account/delegation-value-view/staking-actions/form-dialog.tsx @@ -66,7 +66,7 @@ export const FormDialog = ({ {!!open && !!action && ( <> {getCapitalizedAction(action)} -
+
{validator.name}
diff --git a/apps/minifront/src/components/swap/auction-list/index.tsx b/apps/minifront/src/components/swap/auction-list/index.tsx index 9b544c9ca9..05b8e7b318 100644 --- a/apps/minifront/src/components/swap/auction-list/index.tsx +++ b/apps/minifront/src/components/swap/auction-list/index.tsx @@ -9,7 +9,7 @@ import { bech32mAuctionId } from '@penumbra-zone/bech32m/pauctid'; import { SegmentedPicker } from '@penumbra-zone/ui/components/ui/segmented-picker'; import { useMemo } from 'react'; import { getFilteredAuctionInfos } from './get-filtered-auction-infos'; -import { LayoutGroup } from 'framer-motion'; +import { LayoutGroup, motion } from 'framer-motion'; import { SORT_FUNCTIONS, getMetadata } from './helpers'; const auctionListSelector = (state: AllSlices) => ({ @@ -58,12 +58,12 @@ export const AuctionList = () => { ); return ( - +
- My Auctions + My Auctions -
- {!!filteredAuctionInfos.length && } + + {!!auctionInfos.length && } { { label: 'All', value: 'all' }, ]} /> -
+
diff --git a/apps/minifront/src/components/swap/layout.tsx b/apps/minifront/src/components/swap/layout.tsx index 0e2a32b865..850e75e0b0 100644 --- a/apps/minifront/src/components/swap/layout.tsx +++ b/apps/minifront/src/components/swap/layout.tsx @@ -3,23 +3,26 @@ import { SwapForm } from './swap-form'; import { UnclaimedSwaps } from './unclaimed-swaps'; import { AuctionList } from './auction-list'; import { SwapInfoCard } from './swap-info-card'; +import { LayoutGroup } from 'framer-motion'; export const SwapLayout = () => { return ( -
-
- + +
+
+ - -
+ +
-
- +
+ - + +
-
+ ); }; diff --git a/apps/minifront/src/components/swap/swap-form/estimate-button.tsx b/apps/minifront/src/components/swap/swap-form/estimate-button.tsx new file mode 100644 index 0000000000..602852bc9b --- /dev/null +++ b/apps/minifront/src/components/swap/swap-form/estimate-button.tsx @@ -0,0 +1,40 @@ +import { buttonVariants } from '@penumbra-zone/ui/components/ui/button'; +import { + Tooltip, + TooltipProvider, + TooltipTrigger, + TooltipContent, +} from '@penumbra-zone/ui/components/ui/tooltip'; +import { cn } from '@penumbra-zone/ui/lib/utils'; + +export const EstimateButton = ({ + disabled, + onClick, +}: { + disabled: boolean; + onClick: () => void; +}) => { + return ( + + + { + e.preventDefault(); + onClick(); + }} + disabled={disabled} + > + Estimate + + +

+ Privacy note: This makes a request to your config's gRPC node to simulate a swap of + these assets. That means you are possibly revealing your intent to this node. +

+
+
+
+ ); +}; diff --git a/apps/minifront/src/components/swap/swap-form/index.tsx b/apps/minifront/src/components/swap/swap-form/index.tsx index 4a7268363a..4db93f217c 100644 --- a/apps/minifront/src/components/swap/swap-form/index.tsx +++ b/apps/minifront/src/components/swap/swap-form/index.tsx @@ -1,13 +1,14 @@ import { Button } from '@penumbra-zone/ui/components/ui/button'; import { AllSlices } from '../../../state'; -import { SimulateSwapButton } from './simulate-swap-button'; -import { SimulateSwapResult } from './simulate-swap-result'; import { TokenSwapInput } from './token-swap-input'; import { useStoreShallow } from '../../../utils/use-store-shallow'; import { DurationSlider } from '../duration-slider'; import { InputBlock } from '../../shared/input-block'; import { Output } from './output'; import { Card } from '@penumbra-zone/ui/components/ui/card'; +import { SimulateSwap } from './simulate-swap'; +import { LayoutGroup } from 'framer-motion'; +import { useId } from 'react'; const swapFormSelector = (state: AllSlices) => ({ onSubmit: @@ -23,47 +24,41 @@ export const SwapForm = () => { const { onSubmit, submitButtonLabel, duration, submitButtonDisabled } = useStoreShallow(swapFormSelector); + const sharedLayoutId = useId(); + return ( - - { - e.preventDefault(); - void onSubmit(); - }} - > - + + + { + e.preventDefault(); + void onSubmit(); + }} + > + - -
+ -
-
- - {duration !== 'instant' && ( - -
- -
- )} -
- {duration === 'instant' && } + {duration === 'instant' ? ( + + ) : ( + + )} -
- - {duration === 'instant' && } - + +
); }; diff --git a/apps/minifront/src/components/swap/swap-form/output.tsx b/apps/minifront/src/components/swap/swap-form/output.tsx deleted file mode 100644 index 37791d65df..0000000000 --- a/apps/minifront/src/components/swap/swap-form/output.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { AllSlices } from '../../../state'; -import { useStoreShallow } from '../../../utils/use-store-shallow'; -import { Input } from '@penumbra-zone/ui/components/ui/input'; - -const outputSelector = (state: AllSlices) => ({ - assetOut: state.swap.assetOut, - minOutput: state.swap.dutchAuction.minOutput, - setMinOutput: state.swap.dutchAuction.setMinOutput, - maxOutput: state.swap.dutchAuction.maxOutput, - setMaxOutput: state.swap.dutchAuction.setMaxOutput, -}); - -export const Output = () => { - const { assetOut, minOutput, setMinOutput, maxOutput, setMaxOutput } = - useStoreShallow(outputSelector); - - return ( -
-
- Maximum: - setMaxOutput(e.target.value)} - type='number' - inputMode='decimal' - step='any' - className='text-right' - /> - - {assetOut?.symbol && ( - {assetOut.symbol} - )} -
- -
- Minimum: - - setMinOutput(e.target.value)} - type='number' - inputMode='decimal' - step='any' - className='text-right' - /> - - {assetOut?.symbol && ( - {assetOut.symbol} - )} -
-
- ); -}; diff --git a/apps/minifront/src/components/swap/swap-form/output/estimated-output-explanation.tsx b/apps/minifront/src/components/swap/swap-form/output/estimated-output-explanation.tsx new file mode 100644 index 0000000000..6b690bb321 --- /dev/null +++ b/apps/minifront/src/components/swap/swap-form/output/estimated-output-explanation.tsx @@ -0,0 +1,32 @@ +import { AllSlices } from '../../../../state'; +import { useStoreShallow } from '../../../../utils/use-store-shallow'; +import { formatAmount } from '@penumbra-zone/types/amount'; +import { getDisplayDenomExponent } from '@penumbra-zone/getters/metadata'; +import { getSymbolFromValueView } from '@penumbra-zone/getters/value-view'; + +const estimatedOutputExplanationSelector = (state: AllSlices) => ({ + estimatedOutput: state.swap.dutchAuction.estimatedOutput, + amount: state.swap.amount, + assetIn: state.swap.assetIn, + assetOut: state.swap.assetOut, +}); + +export const EstimatedOutputExplanation = () => { + const { amount, assetIn, estimatedOutput, assetOut } = useStoreShallow( + estimatedOutputExplanationSelector, + ); + + if (!estimatedOutput) return null; + const formattedAmount = formatAmount( + estimatedOutput, + getDisplayDenomExponent.optional()(assetOut), + ); + const asssetInSymbol = getSymbolFromValueView.optional()(assetIn?.balanceView); + + return ( +
+ Based on the current estimated market price of {formattedAmount} {assetOut?.symbol} for{' '} + {amount} {asssetInSymbol}. +
+ ); +}; diff --git a/apps/minifront/src/components/swap/swap-form/output/index.tsx b/apps/minifront/src/components/swap/swap-form/output/index.tsx new file mode 100644 index 0000000000..b9769c4d74 --- /dev/null +++ b/apps/minifront/src/components/swap/swap-form/output/index.tsx @@ -0,0 +1,84 @@ +import { Box } from '@penumbra-zone/ui/components/ui/box'; +import { AllSlices } from '../../../../state'; +import { useStoreShallow } from '../../../../utils/use-store-shallow'; +import { Input } from '@penumbra-zone/ui/components/ui/input'; +import { EstimateButton } from '../estimate-button'; +import { EstimatedOutputExplanation } from './estimated-output-explanation'; +import { motion } from 'framer-motion'; + +const outputSelector = (state: AllSlices) => ({ + assetOut: state.swap.assetOut, + minOutput: state.swap.dutchAuction.minOutput, + setMinOutput: state.swap.dutchAuction.setMinOutput, + maxOutput: state.swap.dutchAuction.maxOutput, + setMaxOutput: state.swap.dutchAuction.setMaxOutput, + estimate: state.swap.dutchAuction.estimate, + estimateButtonDisabled: + state.swap.txInProgress || !state.swap.amount || state.swap.dutchAuction.estimateLoading, +}); + +export const Output = ({ layoutId }: { layoutId: string }) => { + const { + assetOut, + minOutput, + setMinOutput, + maxOutput, + setMaxOutput, + estimate, + estimateButtonDisabled, + } = useStoreShallow(outputSelector); + + return ( + void estimate()} /> + } + > + + +
+ Maximum: + setMaxOutput(e.target.value)} + type='number' + inputMode='decimal' + step='any' + className='text-right' + /> + + {assetOut?.symbol && ( + {assetOut.symbol} + )} +
+ +
+ Minimum: + + setMinOutput(e.target.value)} + type='number' + inputMode='decimal' + step='any' + className='text-right' + /> + + {assetOut?.symbol && ( + {assetOut.symbol} + )} +
+
+ + +
+
+ ); +}; diff --git a/apps/minifront/src/components/swap/swap-form/simulate-swap-button.tsx b/apps/minifront/src/components/swap/swap-form/simulate-swap-button.tsx deleted file mode 100644 index ae9175edff..0000000000 --- a/apps/minifront/src/components/swap/swap-form/simulate-swap-button.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { buttonVariants } from '@penumbra-zone/ui/components/ui/button'; -import { - Tooltip, - TooltipProvider, - TooltipTrigger, - TooltipContent, -} from '@penumbra-zone/ui/components/ui/tooltip'; -import { cn } from '@penumbra-zone/ui/lib/utils'; -import { AllSlices } from '../../../state'; -import { useStoreShallow } from '../../../utils/use-store-shallow'; - -const simulateSwapButtonSelector = (state: AllSlices) => ({ - loading: state.swap.instantSwap.simulateSwapLoading, - simulateSwap: state.swap.instantSwap.simulateSwap, - disabled: state.swap.txInProgress || !state.swap.amount, -}); - -export const SimulateSwapButton = () => { - const { loading, simulateSwap, disabled } = useStoreShallow(simulateSwapButtonSelector); - - return ( -
- - - { - e.preventDefault(); - void simulateSwap(); - }} - disabled={disabled} - > - Estimate - - -

- Privacy note: This makes a request to your config's gRPC node to simulate a swap - of these assets. That means you are possibly revealing your intent to this node. -

-
-
-
-
- ); -}; diff --git a/apps/minifront/src/components/swap/swap-form/simulate-swap-result/index.tsx b/apps/minifront/src/components/swap/swap-form/simulate-swap-result/index.tsx index 7543207861..d40419026b 100644 --- a/apps/minifront/src/components/swap/swap-form/simulate-swap-result/index.tsx +++ b/apps/minifront/src/components/swap/swap-form/simulate-swap-result/index.tsx @@ -1,35 +1,40 @@ -import { useStore } from '../../../../state'; import { ValueViewComponent } from '@penumbra-zone/ui/components/ui/tx/view/value'; import { PriceImpact } from './price-impact'; import { Trace } from './trace'; -import { Box } from '@penumbra-zone/ui/components/ui/box'; +import { motion } from 'framer-motion'; +import { SimulateSwapResult as TSimulateSwapResult } from '../../../../state/swap'; +import { joinLoHiAmount } from '@penumbra-zone/types/amount'; +import { getAmount } from '@penumbra-zone/getters/value-view'; -export const SimulateSwapResult = () => { - const result = useStore(state => state.swap.instantSwap.simulateSwapResult); - - if (!result) return null; +const HIDE = { clipPath: 'polygon(0 0, 100% 0, 100% 0, 0 0)' }; +const SHOW = { clipPath: 'polygon(0 0, 100% 0, 100% 100%, 0 100%)' }; +export const SimulateSwapResult = ({ result }: { result: TSimulateSwapResult }) => { const { unfilled, output, priceImpact, traces, metadataByAssetId } = result; + const hasUnfilled = joinLoHiAmount(getAmount(unfilled)) > 0n; + return ( -
-
-
- - Filled amount -
-
- - Unfilled amount -
+ +
Price impact
+
+ + Filled amount +
+ {hasUnfilled && ( +
+ + Unfilled amount +
+ )}
{!!traces?.length && ( - + <>
{traces.map((trace, index) => ( @@ -37,8 +42,8 @@ export const SimulateSwapResult = () => { ))}
-
+ )} -
+ ); }; diff --git a/apps/minifront/src/components/swap/swap-form/simulate-swap.tsx b/apps/minifront/src/components/swap/swap-form/simulate-swap.tsx new file mode 100644 index 0000000000..e37f476fea --- /dev/null +++ b/apps/minifront/src/components/swap/swap-form/simulate-swap.tsx @@ -0,0 +1,26 @@ +import { Box } from '@penumbra-zone/ui/components/ui/box'; +import { SimulateSwapResult } from './simulate-swap-result'; +import { AllSlices } from '../../../state'; +import { useStoreShallow } from '../../../utils/use-store-shallow'; +import { EstimateButton } from './estimate-button'; + +const simulateSwapSelector = (state: AllSlices) => ({ + simulateSwap: state.swap.instantSwap.simulateSwap, + disabled: + state.swap.txInProgress || !state.swap.amount || state.swap.instantSwap.simulateSwapLoading, + result: state.swap.instantSwap.simulateSwapResult, +}); + +export const SimulateSwap = ({ layoutId }: { layoutId: string }) => { + const { simulateSwap, disabled, result } = useStoreShallow(simulateSwapSelector); + + return ( + void simulateSwap()} />} + layoutId={layoutId} + > + {result && } + + ); +}; diff --git a/apps/minifront/src/components/swap/swap-form/token-swap-input.tsx b/apps/minifront/src/components/swap/swap-form/token-swap-input.tsx index 0e9a009edb..9d5aa2ff4b 100644 --- a/apps/minifront/src/components/swap/swap-form/token-swap-input.tsx +++ b/apps/minifront/src/components/swap/swap-form/token-swap-input.tsx @@ -1,15 +1,9 @@ import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; import { Box } from '@penumbra-zone/ui/components/ui/box'; import BalanceSelector from '../../shared/balance-selector'; -import { - Metadata, - ValueView, -} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; import { ArrowRight } from 'lucide-react'; import { AssetSelector } from '../../shared/asset-selector'; import { BalanceValueView } from '@penumbra-zone/ui/components/ui/balance-value-view'; -import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1/num_pb'; -import { groupByAsset } from '../../../fetchers/balances/by-asset'; import { Input } from '@penumbra-zone/ui/components/ui/input'; import { joinLoHiAmount } from '@penumbra-zone/types/amount'; import { getAmount } from '@penumbra-zone/getters/balances-response'; @@ -17,26 +11,6 @@ import { amountMoreThanBalance } from '../../../state/send'; import { AllSlices } from '../../../state'; import { useStoreShallow } from '../../../utils/use-store-shallow'; -const findMatchingBalance = ( - metadata: Metadata | undefined, - balances: BalancesResponse[], -): ValueView | undefined => { - if (!metadata?.penumbraAssetId) return undefined; - - const match = balances.reduce(groupByAsset, []).find(v => { - if (v.valueView.case !== 'knownAssetId') return false; - return v.valueView.value.metadata?.penumbraAssetId?.equals(metadata.penumbraAssetId); - }); - - if (!match) { - return new ValueView({ - valueView: { case: 'knownAssetId', value: { metadata, amount: new Amount() } }, - }); - } - - return match; -}; - const isValidAmount = (amount: string, assetIn?: BalancesResponse) => Number(amount) >= 0 && (!assetIn || !amountMoreThanBalance(assetIn, amount)); @@ -68,41 +42,41 @@ export const TokenSwapInput = () => { setAssetOut, balancesResponses, } = useStoreShallow(tokenSwapInputSelector); - const balanceOfAssetOut = findMatchingBalance(assetOut, balancesResponses); const maxAmount = getAmount.optional()(assetIn); let maxAmountAsString: string | undefined; if (maxAmount) maxAmountAsString = joinLoHiAmount(maxAmount).toString(); return ( - - { - if (!isValidAmount(e.target.value, assetIn)) return; - setAmount(e.target.value); - }} - /> - -
-
- + +
+
+ { + if (!isValidAmount(e.target.value, assetIn)) return; + setAmount(e.target.value); + }} + /> {assetIn?.balanceView && }
- +
+
+ +
+ + -
- - {balanceOfAssetOut && } +
+ +
diff --git a/apps/minifront/src/components/swap/swap-info-card.tsx b/apps/minifront/src/components/swap/swap-info-card.tsx index 9c54a02728..641360c54a 100644 --- a/apps/minifront/src/components/swap/swap-info-card.tsx +++ b/apps/minifront/src/components/swap/swap-info-card.tsx @@ -26,5 +26,5 @@ const swapInfoCardSelector = (state: AllSlices) => { export const SwapInfoCard = () => { const props = useStoreShallow(swapInfoCardSelector); - return ; + return ; }; diff --git a/apps/minifront/src/components/swap/unclaimed-swaps.tsx b/apps/minifront/src/components/swap/unclaimed-swaps.tsx index 2a42661dbb..7571729028 100644 --- a/apps/minifront/src/components/swap/unclaimed-swaps.tsx +++ b/apps/minifront/src/components/swap/unclaimed-swaps.tsx @@ -27,8 +27,8 @@ const _UnclaimedSwaps = ({ unclaimedSwaps }: { unclaimedSwaps: UnclaimedSwapsWit const { claimSwap, isInProgress } = useStore(unclaimedSwapsSelector); return ( - - Unclaimed Swaps + + Unclaimed Swaps

Swaps on Penumbra are a two step process. The first transaction issues the request and the second claims the result of the swap action. For some reason, these second transactions were diff --git a/apps/minifront/src/state/swap/dutch-auction/index.test.ts b/apps/minifront/src/state/swap/dutch-auction/index.test.ts new file mode 100644 index 0000000000..faea4578c0 --- /dev/null +++ b/apps/minifront/src/state/swap/dutch-auction/index.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { StoreApi, UseBoundStore, create } from 'zustand'; +import { AllSlices, initializeStore } from '../..'; +import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; +import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; + +const mockSimulateClient = vi.hoisted(() => ({ + simulateTrade: vi.fn(), +})); + +vi.mock('../../../clients', () => ({ + simulateClient: mockSimulateClient, +})); + +describe('Dutch auction slice', () => { + let useStore: UseBoundStore>; + + beforeEach(() => { + useStore = create()(initializeStore()) as UseBoundStore>; + }); + + describe('estimate()', () => { + beforeEach(() => { + mockSimulateClient.simulateTrade.mockResolvedValue({ + output: { + output: { + amount: { + hi: 0n, + lo: 222n, + }, + }, + }, + }); + + useStore.setState(state => ({ + ...state, + swap: { + ...state.swap, + assetIn: new BalancesResponse({ + balanceView: { + valueView: { + case: 'knownAssetId', + value: { + amount: { + hi: 0n, + lo: 123n, + }, + metadata: { + base: 'upenumbra', + display: 'penumbra', + denomUnits: [{ denom: 'upenumbra' }, { denom: 'penumbra', exponent: 6 }], + penumbraAssetId: { inner: new Uint8Array([1]) }, + }, + }, + }, + }, + }), + assetOut: new Metadata({ + base: 'ugm', + display: 'gm', + denomUnits: [{ denom: 'ugm' }, { denom: 'gm', exponent: 6 }], + penumbraAssetId: { inner: new Uint8Array([2]) }, + }), + }, + })); + }); + + it('sets `maxOutput` to twice the estimated market price', async () => { + await useStore.getState().swap.dutchAuction.estimate(); + + expect(useStore.getState().swap.dutchAuction.maxOutput).toEqual('0.000444'); + }); + + it('sets `minOutput` to half the estimated market price', async () => { + await useStore.getState().swap.dutchAuction.estimate(); + + expect(useStore.getState().swap.dutchAuction.minOutput).toEqual('0.000111'); + }); + }); +}); diff --git a/apps/minifront/src/state/swap/dutch-auction/index.ts b/apps/minifront/src/state/swap/dutch-auction/index.ts index 8df42b96b6..5177596396 100644 --- a/apps/minifront/src/state/swap/dutch-auction/index.ts +++ b/apps/minifront/src/state/swap/dutch-auction/index.ts @@ -12,6 +12,18 @@ import { } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/auction/v1/auction_pb'; import { viewClient } from '../../../clients'; import { bech32mAssetId } from '@penumbra-zone/bech32m/passet'; +import { sendSimulateTradeRequest } from '../helpers'; +import { fromBaseUnitAmount, multiplyAmountByNumber } from '@penumbra-zone/types/amount'; +import { getDisplayDenomExponent } from '@penumbra-zone/getters/metadata'; +import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1/num_pb'; +import { errorToast } from '@penumbra-zone/ui/lib/toast/presets'; + +/** + * Multipliers to use with the output of the swap simulation, to determine + * reasonable maximum and minimimum outputs for the auction. + */ +const MAX_OUTPUT_ESTIMATE_MULTIPLIER = 2; +const MIN_OUTPUT_ESTIMATE_MULTIPLIER = 0.5; export interface AuctionInfo { id: AuctionId; @@ -30,6 +42,7 @@ interface Actions { withdraw: (auctionId: AuctionId, currentSeqNum: bigint) => Promise; reset: VoidFunction; setFilter: (filter: Filter) => void; + estimate: () => Promise; } interface State { @@ -40,6 +53,8 @@ interface State { loadAuctionInfosAbortController?: AbortController; metadataByAssetId: Record; filter: Filter; + estimateLoading: boolean; + estimatedOutput?: Amount; } export type DutchAuctionSlice = Actions & State; @@ -51,6 +66,8 @@ const INITIAL_STATE: State = { auctionInfos: [], metadataByAssetId: {}, filter: 'active', + estimateLoading: false, + estimatedOutput: undefined, }; export const createDutchAuctionSlice = (): SliceCreator => (set, get) => ({ @@ -58,11 +75,13 @@ export const createDutchAuctionSlice = (): SliceCreator => (s setMinOutput: minOutput => { set(({ swap }) => { swap.dutchAuction.minOutput = minOutput; + swap.dutchAuction.estimatedOutput = undefined; }); }, setMaxOutput: maxOutput => { set(({ swap }) => { swap.dutchAuction.maxOutput = maxOutput; + swap.dutchAuction.estimatedOutput = undefined; }); }, @@ -173,4 +192,38 @@ export const createDutchAuctionSlice = (): SliceCreator => (s swap.dutchAuction.filter = filter; }); }, + + estimate: async () => { + try { + set(({ swap }) => { + swap.dutchAuction.estimateLoading = true; + }); + + const res = await sendSimulateTradeRequest(get().swap); + const estimatedOutputAmount = res.output?.output?.amount; + + if (estimatedOutputAmount) { + const assetOut = get().swap.assetOut; + const exponent = getDisplayDenomExponent(assetOut); + + set(({ swap }) => { + swap.dutchAuction.maxOutput = fromBaseUnitAmount( + multiplyAmountByNumber(estimatedOutputAmount, MAX_OUTPUT_ESTIMATE_MULTIPLIER), + exponent, + ).toString(); + swap.dutchAuction.minOutput = fromBaseUnitAmount( + multiplyAmountByNumber(estimatedOutputAmount, MIN_OUTPUT_ESTIMATE_MULTIPLIER), + exponent, + ).toString(); + swap.dutchAuction.estimatedOutput = estimatedOutputAmount; + }); + } + } catch (e) { + errorToast(e, 'Error estimating swap').render(); + } finally { + set(({ swap }) => { + swap.dutchAuction.estimateLoading = false; + }); + } + }, }); diff --git a/apps/minifront/src/state/swap/helpers.ts b/apps/minifront/src/state/swap/helpers.ts new file mode 100644 index 0000000000..d06204a38b --- /dev/null +++ b/apps/minifront/src/state/swap/helpers.ts @@ -0,0 +1,37 @@ +import { Value } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; +import { SwapSlice } from '.'; +import { + getAssetIdFromValueView, + getDisplayDenomExponentFromValueView, +} from '@penumbra-zone/getters/value-view'; +import { toBaseUnit } from '@penumbra-zone/types/lo-hi'; +import { BigNumber } from 'bignumber.js'; +import { + SimulateTradeRequest, + SimulateTradeResponse, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/dex/v1/dex_pb'; +import { getAssetId } from '@penumbra-zone/getters/metadata'; +import { simulateClient } from '../../clients'; + +export const sendSimulateTradeRequest = ({ + assetIn, + assetOut, + amount, +}: Pick): Promise => { + if (!assetIn || !assetOut) throw new Error('Both asset in and out need to be set'); + + const swapInValue = new Value({ + assetId: getAssetIdFromValueView(assetIn.balanceView), + amount: toBaseUnit( + BigNumber(amount || 0), + getDisplayDenomExponentFromValueView(assetIn.balanceView), + ), + }); + + const req = new SimulateTradeRequest({ + input: swapInValue, + output: getAssetId(assetOut), + }); + + return simulateClient.simulateTrade(req); +}; diff --git a/apps/minifront/src/state/swap/instant-swap.ts b/apps/minifront/src/state/swap/instant-swap.ts index 47b4fc8a9e..95555a6d2a 100644 --- a/apps/minifront/src/state/swap/instant-swap.ts +++ b/apps/minifront/src/state/swap/instant-swap.ts @@ -12,11 +12,10 @@ import { getAddressByIndex } from '../../fetchers/address'; import { StateCommitment } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/crypto/tct/v1/tct_pb'; import { errorToast } from '@penumbra-zone/ui/lib/toast/presets'; import { - SimulateTradeRequest, SwapExecution, SwapExecution_Trace, } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/dex/v1/dex_pb'; -import { simulateClient, viewClient } from '../../clients'; +import { viewClient } from '../../clients'; import { getAssetIdFromValueView, getDisplayDenomExponentFromValueView, @@ -31,6 +30,7 @@ import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/nu import { divideAmounts } from '@penumbra-zone/types/amount'; import { bech32mAssetId } from '@penumbra-zone/bech32m/passet'; import { SwapSlice } from '.'; +import { sendSimulateTradeRequest } from './helpers'; const getMetadataByAssetId = async ( traces: SwapExecution_Trace[] = [], @@ -91,29 +91,14 @@ export const createInstantSwapSlice = (): SliceCreator => (set swap.instantSwap.simulateSwapLoading = true; }); - const assetIn = get().swap.assetIn; - const assetOut = get().swap.assetOut; - if (!assetIn || !assetOut) throw new Error('Both asset in and out need to be set'); - - const swapInValue = new Value({ - assetId: getAssetIdFromValueView(assetIn.balanceView), - amount: toBaseUnit( - BigNumber(get().swap.amount || 0), - getDisplayDenomExponentFromValueView(assetIn.balanceView), - ), - }); - const req = new SimulateTradeRequest({ - input: swapInValue, - output: getAssetId(assetOut), - }); - const res = await simulateClient.simulateTrade(req); + const res = await sendSimulateTradeRequest(get().swap); const output = new ValueView({ valueView: { case: 'knownAssetId', value: { amount: res.output?.output?.amount, - metadata: assetOut, + metadata: get().swap.assetOut, }, }, }); @@ -123,7 +108,7 @@ export const createInstantSwapSlice = (): SliceCreator => (set case: 'knownAssetId', value: { amount: res.unfilled?.amount, - metadata: getMetadata(assetIn.balanceView), + metadata: getMetadata(get().swap.assetIn?.balanceView), }, }, }); diff --git a/packages/getters/src/value-view.ts b/packages/getters/src/value-view.ts index d0fc2a28af..a050bab66f 100644 --- a/packages/getters/src/value-view.ts +++ b/packages/getters/src/value-view.ts @@ -40,7 +40,11 @@ export const getValidatorIdentityKeyFromValueView = getValidatorInfoFromValueVie getIdentityKeyFromValidatorInfo, ); -export const getDisplayDenomExponentFromValueView = getMetadata.pipe(getDisplayDenomExponent); +export const getDisplayDenomExponentFromValueView = createGetter((valueView?: ValueView) => + valueView?.valueView.case === 'knownAssetId' + ? getDisplayDenomExponent.optional()(valueView.valueView.value.metadata) + : undefined, +); export const getAssetIdFromValueView = createGetter((v?: ValueView) => { switch (v?.valueView.case) { @@ -57,7 +61,10 @@ export const getAmount = createGetter( (valueView?: ValueView) => valueView?.valueView.value?.amount, ); -export const getSymbolFromValueView = getMetadata.pipe(getSymbol); +export const getSymbolFromValueView = createGetter((valueView?: ValueView) => { + const metadata = getMetadata.optional()(valueView); + return getSymbol.optional()(metadata); +}); export const getDisplayDenomFromView = createGetter((view?: ValueView) => { if (view?.valueView.case === 'unknownAssetId') { diff --git a/packages/ui/components/ui/balance-value-view.tsx b/packages/ui/components/ui/balance-value-view.tsx index d470eac3f5..e66ae82110 100644 --- a/packages/ui/components/ui/balance-value-view.tsx +++ b/packages/ui/components/ui/balance-value-view.tsx @@ -1,15 +1,34 @@ import { ValueView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; import { WalletIcon } from 'lucide-react'; -import { ValueViewComponent } from './tx/view/value'; +import { + getAmount, + getDisplayDenomExponentFromValueView, + getSymbolFromValueView, +} from '@penumbra-zone/getters/value-view'; +import { formatAmount } from '@penumbra-zone/types/amount'; +import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1/num_pb'; +import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from './tooltip'; /** * Renders a `ValueView` as a balance with a wallet icon. */ export const BalanceValueView = ({ valueView }: { valueView: ValueView }) => { + const exponent = getDisplayDenomExponentFromValueView.optional()(valueView); + const symbol = getSymbolFromValueView.optional()(valueView); + const amount = getAmount.optional()(valueView) ?? new Amount({ hi: 0n, lo: 0n }); + const formattedAmount = formatAmount(amount, exponent); + return ( -

- - -
+ + + + + + {formattedAmount} + + + Your balance {symbol && <>of {symbol}} + + ); }; diff --git a/packages/ui/components/ui/box.tsx b/packages/ui/components/ui/box.tsx index e2461e5606..120ed28095 100644 --- a/packages/ui/components/ui/box.tsx +++ b/packages/ui/components/ui/box.tsx @@ -1,7 +1,10 @@ -import { VariantProps, cva } from 'class-variance-authority'; -import { PropsWithChildren } from 'react'; +import { cva, VariantProps } from 'class-variance-authority'; +import { motion } from 'framer-motion'; +import { PropsWithChildren, ReactNode } from 'react'; +import { RESOLVED_TAILWIND_CONFIG } from '@penumbra-zone/tailwind-config/resolved-tailwind-config'; +import { cn } from '../../lib/utils'; -const variants = cva('overflow-hidden rounded-lg border bg-background', { +const variants = cva('rounded-lg border bg-background', { variants: { spacing: { /** Useful for e.g., wrapping around a transparent ``. */ @@ -28,11 +31,49 @@ export const Box = ({ label, spacing, state, -}: PropsWithChildren & { label?: string }>) => { + layout, + layoutId, + headerContent, +}: PropsWithChildren< + VariantProps & { + label?: ReactNode; + layout?: boolean; + layoutId?: string; + headerContent?: ReactNode; + } +>) => { return ( -
- {label &&
{label}
} - {children} -
+ + {(label ?? headerContent) && ( +
+ {label && ( + +
{label}
+
+ )} + {headerContent && ( + + {headerContent} + + )} +
+ )} + + {children && ( + + {children} + + )} +
); }; diff --git a/packages/ui/components/ui/card.tsx b/packages/ui/components/ui/card.tsx index e50d1a3380..e20249008e 100644 --- a/packages/ui/components/ui/card.tsx +++ b/packages/ui/components/ui/card.tsx @@ -1,26 +1,28 @@ import * as React from 'react'; import { cn } from '../../lib/utils'; +import { motion } from 'framer-motion'; export interface CardProps extends React.HTMLAttributes { gradient?: boolean; light?: boolean; + layout?: boolean; } const Card = React.forwardRef( - ({ className, gradient, light, children, ...props }, ref) => { + ({ className, gradient, light, children, layout }, ref) => { const baseClasses = 'rounded-lg shadow-sm p-[30px] overflow-hidden'; return ( -
{children} -
+ ); }, ); diff --git a/packages/ui/components/ui/dialog.tsx b/packages/ui/components/ui/dialog.tsx index 2a0f68730e..31a52702db 100644 --- a/packages/ui/components/ui/dialog.tsx +++ b/packages/ui/components/ui/dialog.tsx @@ -5,6 +5,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog'; import { Cross2Icon } from '@radix-ui/react-icons'; import { cn } from '../../lib/utils'; import { cva, VariantProps } from 'class-variance-authority'; +import { motion } from 'framer-motion'; /** * You can use a `` in two ways. @@ -83,7 +84,6 @@ const dialogContentVariants = cva( '-translate-y-1/2', 'gap-4', 'rounded-lg', - 'bg-card-radial', 'shadow-lg', 'duration-200', 'data-[state=open]:animate-in', @@ -114,16 +114,19 @@ const dialogContentVariants = cva( ); interface DialogContentProps extends VariantProps { children?: React.ReactNode; + layoutId?: string; } const DialogContent = React.forwardRef< React.ElementRef, DialogContentProps ->(({ children, size }, ref) => ( +>(({ children, size, layoutId }, ref) => ( - {children} + + {children} + )); diff --git a/packages/ui/components/ui/gradient-header.tsx b/packages/ui/components/ui/gradient-header.tsx index 2969453c9f..64374290ea 100644 --- a/packages/ui/components/ui/gradient-header.tsx +++ b/packages/ui/components/ui/gradient-header.tsx @@ -1,8 +1,21 @@ +import { motion } from 'framer-motion'; + /** * A header with text whose color is a gradient of brand colors. */ -export const GradientHeader = ({ children }: { children: string }) => ( -

+export const GradientHeader = ({ + children, + layout, + layoutId, +}: { + children: string; + layout?: boolean; + layoutId?: string; +}) => ( + {children} -

+ );