From 37508fb9a99f35683f5f8818509ae2ea74567c13 Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Fri, 19 Apr 2024 10:49:12 -0700 Subject: [PATCH 01/16] Build out Dutch auction UI --- apps/minifront/public/auction-gradient.svg | 14 +++ .../src/components/dashboard/constants.ts | 9 +- .../src/components/metadata/content.ts | 4 + .../src/components/metadata/paths.ts | 1 + apps/minifront/src/components/root-router.tsx | 18 +++- .../src/components/send/constants.ts | 4 +- .../src/components/shared/asset-selector.tsx | 19 ++-- .../components/shared/edu-panels/content.tsx | 3 + apps/minifront/src/components/shared/tabs.tsx | 10 +- .../swap/dutch-auction/duration-slider.tsx | 32 +++++++ .../swap/dutch-auction/dutch-auction-form.tsx | 61 ++++++++++++ .../dutch-auction/dutch-auction-loader.ts | 21 +++++ .../components/swap/dutch-auction/index.tsx | 23 +++++ .../components/swap/dutch-auction/prices.tsx | 64 +++++++++++++ apps/minifront/src/components/swap/helpers.ts | 34 +++++++ apps/minifront/src/components/swap/layout.tsx | 39 ++++---- .../swap/{ => swap}/asset-out-box.tsx | 18 +++- .../src/components/swap/swap/index.tsx | 22 +++++ .../components/swap/{ => swap}/swap-form.tsx | 6 +- .../swap/{ => swap}/swap-loader.tsx | 43 ++------- .../swap/{ => swap}/unclaimed-swaps.tsx | 4 +- .../state/dutch-auction/assemble-request.ts | 52 ++++++++++ .../src/state/dutch-auction/constants.ts | 26 +++++ .../src/state/dutch-auction/index.ts | 94 +++++++++++++++++++ apps/minifront/src/state/index.ts | 3 + packages/constants/src/assets.test.ts | 27 +++++- packages/constants/src/assets.ts | 9 ++ packages/perspective/plan/view-action-plan.ts | 7 ++ .../perspective/transaction/classification.ts | 4 +- .../perspective/transaction/classify.test.ts | 29 ++++++ packages/perspective/transaction/classify.ts | 18 ++-- packages/types/src/amount.test.ts | 10 ++ packages/types/src/amount.ts | 2 + packages/ui/components/ui/slider.tsx | 30 ++++++ .../tx/view/action-dutch-auction-schedule.tsx | 63 +++++++++++++ .../ui/components/ui/tx/view/action-view.tsx | 4 + packages/ui/package.json | 1 + pnpm-lock.yaml | 34 +++++++ 38 files changed, 770 insertions(+), 92 deletions(-) create mode 100644 apps/minifront/public/auction-gradient.svg create mode 100644 apps/minifront/src/components/swap/dutch-auction/duration-slider.tsx create mode 100644 apps/minifront/src/components/swap/dutch-auction/dutch-auction-form.tsx create mode 100644 apps/minifront/src/components/swap/dutch-auction/dutch-auction-loader.ts create mode 100644 apps/minifront/src/components/swap/dutch-auction/index.tsx create mode 100644 apps/minifront/src/components/swap/dutch-auction/prices.tsx create mode 100644 apps/minifront/src/components/swap/helpers.ts rename apps/minifront/src/components/swap/{ => swap}/asset-out-box.tsx (90%) create mode 100644 apps/minifront/src/components/swap/swap/index.tsx rename apps/minifront/src/components/swap/{ => swap}/swap-form.tsx (89%) rename apps/minifront/src/components/swap/{ => swap}/swap-loader.tsx (60%) rename apps/minifront/src/components/swap/{ => swap}/unclaimed-swaps.tsx (95%) create mode 100644 apps/minifront/src/state/dutch-auction/assemble-request.ts create mode 100644 apps/minifront/src/state/dutch-auction/constants.ts create mode 100644 apps/minifront/src/state/dutch-auction/index.ts create mode 100644 packages/ui/components/ui/slider.tsx create mode 100644 packages/ui/components/ui/tx/view/action-dutch-auction-schedule.tsx diff --git a/apps/minifront/public/auction-gradient.svg b/apps/minifront/public/auction-gradient.svg new file mode 100644 index 0000000000..d1f84043ec --- /dev/null +++ b/apps/minifront/public/auction-gradient.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/apps/minifront/src/components/dashboard/constants.ts b/apps/minifront/src/components/dashboard/constants.ts index 63c410c070..69ee87c964 100644 --- a/apps/minifront/src/components/dashboard/constants.ts +++ b/apps/minifront/src/components/dashboard/constants.ts @@ -1,11 +1,12 @@ import { DashboardTabMap } from './types'; import { PagePath } from '../metadata/paths'; import { EduPanel } from '../shared/edu-panels/content'; +import { Tab } from '../shared/tabs'; -export const dashboardTabs = [ - { title: 'Assets', href: PagePath.DASHBOARD, active: true }, - { title: 'Transactions', href: PagePath.TRANSACTIONS, active: true }, - { title: 'NFTs', href: PagePath.NFTS, active: false }, +export const dashboardTabs: Tab[] = [ + { title: 'Assets', href: PagePath.DASHBOARD, enabled: true }, + { title: 'Transactions', href: PagePath.TRANSACTIONS, enabled: true }, + { title: 'NFTs', href: PagePath.NFTS, enabled: false }, ]; export const dashboardTabsHelper: DashboardTabMap = { diff --git a/apps/minifront/src/components/metadata/content.ts b/apps/minifront/src/components/metadata/content.ts index 42d6aba7cb..b0c9f9b2f2 100644 --- a/apps/minifront/src/components/metadata/content.ts +++ b/apps/minifront/src/components/metadata/content.ts @@ -39,6 +39,10 @@ export const metadata: Record = { title: 'Penumbra | Swap', description: eduPanelContent[EduPanel.TEMP_FILLER], }, + [PagePath.SWAP_AUCTION]: { + title: 'Penumbra | Auction', + description: eduPanelContent[EduPanel.TEMP_FILLER], + }, [PagePath.STAKING]: { title: 'Penumbra | Staking', description: eduPanelContent[EduPanel.TEMP_FILLER], diff --git a/apps/minifront/src/components/metadata/paths.ts b/apps/minifront/src/components/metadata/paths.ts index 82aa0fb444..6b43afa2dd 100644 --- a/apps/minifront/src/components/metadata/paths.ts +++ b/apps/minifront/src/components/metadata/paths.ts @@ -1,6 +1,7 @@ export enum PagePath { INDEX = '/', SWAP = '/swap', + SWAP_AUCTION = '/swap/auction', SEND = '/send', STAKING = '/staking', RECEIVE = '/send/receive', diff --git a/apps/minifront/src/components/root-router.tsx b/apps/minifront/src/components/root-router.tsx index 2c84b2f02e..1fb7ac9eb3 100644 --- a/apps/minifront/src/components/root-router.tsx +++ b/apps/minifront/src/components/root-router.tsx @@ -10,10 +10,13 @@ import { SendAssetBalanceLoader, SendForm } from './send/send-form'; import { Receive } from './send/receive'; import { ErrorBoundary } from './shared/error-boundary'; import { SwapLayout } from './swap/layout'; -import { SwapLoader } from './swap/swap-loader'; +import { SwapLoader } from './swap/swap/swap-loader'; import { StakingLayout, StakingLoader } from './staking/layout'; import { IbcLoader } from './ibc/ibc-loader'; import { IbcLayout } from './ibc/layout'; +import { Swap } from './swap/swap'; +import { DutchAuction } from './swap/dutch-auction'; +import { DutchAuctionLoader } from './swap/dutch-auction/dutch-auction-loader'; export const rootRouter = createHashRouter([ { @@ -57,8 +60,19 @@ export const rootRouter = createHashRouter([ }, { path: PagePath.SWAP, - loader: SwapLoader, element: , + children: [ + { + index: true, + loader: SwapLoader, + element: , + }, + { + path: PagePath.SWAP_AUCTION, + loader: DutchAuctionLoader, + element: , + }, + ], }, { path: PagePath.TRANSACTION_DETAILS, diff --git a/apps/minifront/src/components/send/constants.ts b/apps/minifront/src/components/send/constants.ts index 5c949f6225..86a8b6f651 100644 --- a/apps/minifront/src/components/send/constants.ts +++ b/apps/minifront/src/components/send/constants.ts @@ -16,6 +16,6 @@ export const sendTabsHelper: SendTabMap = { }; export const sendTabs = [ - { title: 'Send', href: PagePath.SEND, active: true }, - { title: 'Receive', href: PagePath.RECEIVE, active: true }, + { title: 'Send', href: PagePath.SEND, enabled: true }, + { title: 'Receive', href: PagePath.RECEIVE, enabled: true }, ]; diff --git a/apps/minifront/src/components/shared/asset-selector.tsx b/apps/minifront/src/components/shared/asset-selector.tsx index a6a763b224..57c8c57013 100644 --- a/apps/minifront/src/components/shared/asset-selector.tsx +++ b/apps/minifront/src/components/shared/asset-selector.tsx @@ -14,10 +14,9 @@ import { ValueViewComponent } from '@penumbra-zone/ui/components/ui/tx/view/valu import { useEffect, useMemo, useState } from 'react'; import { IconInput } from '@penumbra-zone/ui/components/ui/icon-input'; import { MagnifyingGlassIcon } from '@radix-ui/react-icons'; -import { SwapLoaderResponse } from '../swap/swap-loader'; -import { useLoaderData } from 'react-router-dom'; interface AssetSelectorProps { + assets: Metadata[]; value?: Metadata; onChange: (metadata: Metadata) => void; /** @@ -46,8 +45,7 @@ const switchAssetIfNecessary = ({ } }; -const useFilteredAssets = ({ value, onChange, filter }: AssetSelectorProps) => { - const { assets } = useLoaderData() as SwapLoaderResponse; +const useFilteredAssets = ({ assets, value, onChange, filter }: AssetSelectorProps) => { const sortedAssets = useMemo( () => [...assets].sort((a, b) => @@ -66,7 +64,7 @@ const useFilteredAssets = ({ value, onChange, filter }: AssetSelectorProps) => { [filter, value, filteredAssets, onChange], ); - return { assets: filteredAssets, search, setSearch }; + return { filteredAssets, search, setSearch }; }; const bySearch = (search: string) => (asset: Metadata) => @@ -80,8 +78,13 @@ const bySearch = (search: string) => (asset: Metadata) => * For an asset selector that picks from the user's balances, use * ``. */ -export const AssetSelector = ({ onChange, value, filter }: AssetSelectorProps) => { - const { assets, search, setSearch } = useFilteredAssets({ value, onChange, filter }); +export const AssetSelector = ({ assets, onChange, value, filter }: AssetSelectorProps) => { + const { filteredAssets, search, setSearch } = useFilteredAssets({ + assets, + value, + onChange, + filter, + }); /** * @todo: Refactor to not use `ValueViewComponent`, since it's not intended to @@ -109,7 +112,7 @@ export const AssetSelector = ({ onChange, value, filter }: AssetSelectorProps) = onChange={setSearch} placeholder='Search assets...' /> - {assets.map(metadata => ( + {filteredAssets.map(metadata => (
= { 'IBC to a connected chain. Note that if the chain is a transparent chain, the transaction will be visible to others.', [EduPanel.SWAP]: 'Shielded swaps between any kind of cryptoasset, with sealed-bid, batch pricing and no frontrunning. Only the batch totals are revealed, providing long-term privacy. Penumbra has no MEV, because transactions do not leak data about user activity.', + [EduPanel.SWAP_AUCTION]: + "Offer a specific quantity of cryptocurrency at decreasing prices until all the tokens are sold. Buyers can place bids at the price they're willing to pay, with the auction concluding when all tokens are sold or when the auction time expires. This mechanism allows for price discovery based on market demand, with participants potentially acquiring tokens at prices lower than initially offered.", [EduPanel.STAKING]: 'Explore the available validator nodes and their associated rewards, performance metrics, and staking requirements. Select the validator you wish to delegate your tokens to, based on factors like uptime, reputation, and expected returns. Stay informed about validator performance updates, rewards distribution, and any network upgrades to ensure a seamless staking experience.', [EduPanel.TEMP_FILLER]: diff --git a/apps/minifront/src/components/shared/tabs.tsx b/apps/minifront/src/components/shared/tabs.tsx index 6bc45740ed..0f39aa2219 100644 --- a/apps/minifront/src/components/shared/tabs.tsx +++ b/apps/minifront/src/components/shared/tabs.tsx @@ -3,8 +3,14 @@ import { cn } from '@penumbra-zone/ui/lib/utils'; import { PagePath } from '../metadata/paths'; import { useNavigate } from 'react-router-dom'; +export interface Tab { + title: string; + enabled: boolean; + href: PagePath; +} + interface TabsProps { - tabs: { title: string; active: boolean; href: PagePath }[]; + tabs: Tab[]; activeTab: PagePath; className?: string; } @@ -21,7 +27,7 @@ export const Tabs = ({ tabs, activeTab, className }: TabsProps) => { > {tabs.map( tab => - tab.active && ( + tab.enabled && ( + + ); +}; diff --git a/apps/minifront/src/components/swap/dutch-auction/dutch-auction-loader.ts b/apps/minifront/src/components/swap/dutch-auction/dutch-auction-loader.ts new file mode 100644 index 0000000000..24c4f8a8c5 --- /dev/null +++ b/apps/minifront/src/components/swap/dutch-auction/dutch-auction-loader.ts @@ -0,0 +1,21 @@ +import { throwIfPraxNotConnectedTimeout } from '@penumbra-zone/client'; +import { getSwappableBalancesResponses } from '../helpers'; +import { useStore } from '../../../state'; +import { getAllAssets } from '../../../fetchers/assets'; + +export const DutchAuctionLoader = async () => { + await throwIfPraxNotConnectedTimeout(); + + const [assets, balancesResponses] = await Promise.all([ + getAllAssets(), + getSwappableBalancesResponses(), + ]); + useStore.getState().dutchAuction.setBalancesResponses(balancesResponses); + + if (balancesResponses[0]) { + useStore.getState().dutchAuction.setAssetIn(balancesResponses[0]); + useStore.getState().dutchAuction.setAssetOut(assets[0]!); + } + + return assets; +}; diff --git a/apps/minifront/src/components/swap/dutch-auction/index.tsx b/apps/minifront/src/components/swap/dutch-auction/index.tsx new file mode 100644 index 0000000000..e00a3c9dbb --- /dev/null +++ b/apps/minifront/src/components/swap/dutch-auction/index.tsx @@ -0,0 +1,23 @@ +import { Card } from '@penumbra-zone/ui/components/ui/card'; +import { EduPanel } from '../../shared/edu-panels/content'; +import { EduInfoCard } from '../../shared/edu-panels/edu-info-card'; +import { DutchAuctionForm } from './dutch-auction-form'; + +export const DutchAuction = () => { + return ( +
+
+ + + + + + +
+ ); +}; diff --git a/apps/minifront/src/components/swap/dutch-auction/prices.tsx b/apps/minifront/src/components/swap/dutch-auction/prices.tsx new file mode 100644 index 0000000000..6ff8c82b14 --- /dev/null +++ b/apps/minifront/src/components/swap/dutch-auction/prices.tsx @@ -0,0 +1,64 @@ +import { AllSlices } from '../../../state'; +import { useStoreShallow } from '../../../utils/use-store-shallow'; +import { Input } from '@penumbra-zone/ui/components/ui/input'; +import { AssetSelector } from '../../shared/asset-selector'; +import { getAssetIdFromValueView } from '@penumbra-zone/getters/value-view'; +import { useLoaderData } from 'react-router-dom'; +import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; + +const pricesSelector = (state: AllSlices) => ({ + assetIn: state.dutchAuction.assetIn, + assetOut: state.dutchAuction.assetOut, + setAssetOut: state.dutchAuction.setAssetOut, + minOutput: state.dutchAuction.minOutput, + setMinOutput: state.dutchAuction.setMinOutput, + maxOutput: state.dutchAuction.maxOutput, + setMaxOutput: state.dutchAuction.setMaxOutput, +}); + +export const Prices = () => { + const { minOutput, setMinOutput, maxOutput, setMaxOutput, assetIn, assetOut, setAssetOut } = + useStoreShallow(pricesSelector); + const assetInId = getAssetIdFromValueView(assetIn?.balanceView); + const assets = useLoaderData() as Metadata[]; + + return ( +
+
+ Min: + setMinOutput(e.target.value)} + type='number' + inputMode='numeric' + className='grow' + /> +
+ +
+ Max: + setMaxOutput(e.target.value)} + type='number' + inputMode='numeric' + className='grow text-right' + /> +
+ +
+ !asset.penumbraAssetId?.equals(assetInId)} + /> +
+
+ ); +}; diff --git a/apps/minifront/src/components/swap/helpers.ts b/apps/minifront/src/components/swap/helpers.ts new file mode 100644 index 0000000000..c5e257d91f --- /dev/null +++ b/apps/minifront/src/components/swap/helpers.ts @@ -0,0 +1,34 @@ +import { assetPatterns } from '@penumbra-zone/constants/assets'; +import { getBalances } from '../../fetchers/balances'; +import { + getAmount, + getDisplayDenomExponentFromValueView, + getDisplayDenomFromView, +} from '@penumbra-zone/getters/value-view'; +import { fromBaseUnitAmount } from '@penumbra-zone/types/amount'; +import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; + +const byBalanceDescending = (a: BalancesResponse, b: BalancesResponse) => { + const aExponent = getDisplayDenomExponentFromValueView(a.balanceView); + const bExponent = getDisplayDenomExponentFromValueView(b.balanceView); + const aAmount = fromBaseUnitAmount(getAmount(a.balanceView), aExponent); + const bAmount = fromBaseUnitAmount(getAmount(b.balanceView), bExponent); + + return bAmount.comparedTo(aAmount); +}; + +const nonSwappableAssetPatterns = [ + assetPatterns.lpNft, + assetPatterns.proposalNft, + assetPatterns.votingReceipt, +]; + +const isSwappable = (balancesResponse: BalancesResponse) => + nonSwappableAssetPatterns.every( + pattern => !pattern.matches(getDisplayDenomFromView(balancesResponse.balanceView)), + ); + +export const getSwappableBalancesResponses = async (): Promise => { + const balancesResponses = await getBalances(); + return balancesResponses.filter(isSwappable).sort(byBalanceDescending); +}; diff --git a/apps/minifront/src/components/swap/layout.tsx b/apps/minifront/src/components/swap/layout.tsx index 7bbf4c3bfb..524b247d4c 100644 --- a/apps/minifront/src/components/swap/layout.tsx +++ b/apps/minifront/src/components/swap/layout.tsx @@ -1,25 +1,32 @@ -import { Card } from '@penumbra-zone/ui/components/ui/card'; -import { EduInfoCard } from '../shared/edu-panels/edu-info-card'; -import { EduPanel } from '../shared/edu-panels/content'; -import { SwapForm } from './swap-form'; -import { UnclaimedSwaps } from './unclaimed-swaps'; import { RestrictMaxWidth } from '../shared/restrict-max-width'; +import { Tab, Tabs } from '../shared/tabs'; +import { PagePath } from '../metadata/paths'; +import { usePagePath } from '../../fetchers/page-path'; +import { Outlet } from 'react-router-dom'; + +const TABS: Tab[] = [ + { + title: 'Swap', + enabled: true, + href: PagePath.SWAP, + }, + { + title: 'Auction', + enabled: true, + href: PagePath.SWAP_AUCTION, + }, +]; export const SwapLayout = () => { + const pathname = usePagePath<(typeof TABS)[number]['href']>(); + return ( -
- - - - - +
+
+ + ); }; diff --git a/apps/minifront/src/components/swap/asset-out-box.tsx b/apps/minifront/src/components/swap/swap/asset-out-box.tsx similarity index 90% rename from apps/minifront/src/components/swap/asset-out-box.tsx rename to apps/minifront/src/components/swap/swap/asset-out-box.tsx index 7562970ac2..e9c13ca868 100644 --- a/apps/minifront/src/components/swap/asset-out-box.tsx +++ b/apps/minifront/src/components/swap/swap/asset-out-box.tsx @@ -1,5 +1,5 @@ -import { useStore } from '../../state'; -import { SimulateSwapResult, swapSelector } from '../../state/swap'; +import { useStore } from '../../../state'; +import { SimulateSwapResult, swapSelector } from '../../../state/swap'; import { Tooltip, TooltipContent, @@ -7,13 +7,13 @@ import { TooltipTrigger, } from '@penumbra-zone/ui/components/ui/tooltip'; import { buttonVariants } from '@penumbra-zone/ui/components/ui/button'; -import { AssetSelector } from '../shared/asset-selector'; +import { AssetSelector } from '../../shared/asset-selector'; import { Metadata, ValueView, } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; import { ValueViewComponent } from '@penumbra-zone/ui/components/ui/tx/view/value'; -import { groupByAsset } from '../../fetchers/balances/by-asset'; +import { groupByAsset } from '../../../fetchers/balances/by-asset'; import { cn } from '@penumbra-zone/ui/lib/utils'; import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1/num_pb'; @@ -22,6 +22,8 @@ import { formatNumber, isZero } from '@penumbra-zone/types/amount'; import { getAmount } from '@penumbra-zone/getters/value-view'; import { WalletIcon } from '@penumbra-zone/ui/components/ui/icons/wallet'; import { getAssetIdFromBalancesResponseOptional } from '@penumbra-zone/getters/balances-response'; +import { useLoaderData } from 'react-router-dom'; +import { SwapLoaderResponse } from './swap-loader'; const findMatchingBalance = ( metadata: Metadata | undefined, @@ -51,6 +53,7 @@ export const AssetOutBox = ({ balances }: AssetOutBoxProps) => { const { assetIn, assetOut, setAssetOut, simulateSwap, simulateOutLoading, simulateOutResult } = useStore(swapSelector); + const { assets } = useLoaderData() as SwapLoaderResponse; const matchingBalance = findMatchingBalance(assetOut, balances); const assetInId = getAssetIdFromBalancesResponseOptional(assetIn); const filter = assetInId @@ -72,7 +75,12 @@ export const AssetOutBox = ({ balances }: AssetOutBoxProps) => {
- +
diff --git a/apps/minifront/src/components/swap/swap/index.tsx b/apps/minifront/src/components/swap/swap/index.tsx new file mode 100644 index 0000000000..69f8ce1094 --- /dev/null +++ b/apps/minifront/src/components/swap/swap/index.tsx @@ -0,0 +1,22 @@ +import { Card } from '@penumbra-zone/ui/components/ui/card'; +import { EduInfoCard } from '../../shared/edu-panels/edu-info-card'; +import { EduPanel } from '../../shared/edu-panels/content'; +import { SwapForm } from './swap-form'; +import { UnclaimedSwaps } from './unclaimed-swaps'; + +export const Swap = () => { + return ( +
+ + + + + +
+ ); +}; diff --git a/apps/minifront/src/components/swap/swap-form.tsx b/apps/minifront/src/components/swap/swap/swap-form.tsx similarity index 89% rename from apps/minifront/src/components/swap/swap-form.tsx rename to apps/minifront/src/components/swap/swap/swap-form.tsx index aa11d8b1a7..4003589c18 100644 --- a/apps/minifront/src/components/swap/swap-form.tsx +++ b/apps/minifront/src/components/swap/swap/swap-form.tsx @@ -1,8 +1,8 @@ import { Button } from '@penumbra-zone/ui/components/ui/button'; -import InputToken from '../shared/input-token'; +import InputToken from '../../shared/input-token'; import { useLoaderData } from 'react-router-dom'; -import { useStore } from '../../state'; -import { swapSelector, swapValidationErrors } from '../../state/swap'; +import { useStore } from '../../../state'; +import { swapSelector, swapValidationErrors } from '../../../state/swap'; import { AssetOutBox } from './asset-out-box'; import { SwapLoaderResponse } from './swap-loader'; diff --git a/apps/minifront/src/components/swap/swap-loader.tsx b/apps/minifront/src/components/swap/swap/swap-loader.tsx similarity index 60% rename from apps/minifront/src/components/swap/swap-loader.tsx rename to apps/minifront/src/components/swap/swap/swap-loader.tsx index dd8afdede9..a397b103cb 100644 --- a/apps/minifront/src/components/swap/swap-loader.tsx +++ b/apps/minifront/src/components/swap/swap/swap-loader.tsx @@ -1,24 +1,17 @@ import { LoaderFunction } from 'react-router-dom'; -import { getBalances } from '../../fetchers/balances'; -import { useStore } from '../../state'; +import { useStore } from '../../../state'; import { throwIfPraxNotConnectedTimeout } from '@penumbra-zone/client'; import { BalancesResponse, SwapRecord, } 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'; -import { fetchUnclaimedSwaps } from '../../fetchers/unclaimed-swaps'; -import { viewClient } from '../../clients'; -import { assetPatterns } from '@penumbra-zone/constants/assets'; -import { - getAmount, - getDisplayDenomExponentFromValueView, - getDisplayDenomFromView, -} from '@penumbra-zone/getters/value-view'; +import { fetchUnclaimedSwaps } from '../../../fetchers/unclaimed-swaps'; +import { viewClient } from '../../../clients'; import { getSwapAsset1, getSwapAsset2 } from '@penumbra-zone/getters/swap-record'; import { uint8ArrayToBase64 } from '@penumbra-zone/types/base64'; -import { fromBaseUnitAmount } from '@penumbra-zone/types/amount'; -import { getAllAssets } from '../../fetchers/assets'; +import { getSwappableBalancesResponses } from '../helpers'; +import { getAllAssets } from '../../../fetchers/assets'; export interface UnclaimedSwapsWithMetadata { swap: SwapRecord; @@ -32,34 +25,16 @@ export interface SwapLoaderResponse { assets: Metadata[]; } -const byBalanceDescending = (a: BalancesResponse, b: BalancesResponse) => { - const aExponent = getDisplayDenomExponentFromValueView(a.balanceView); - const bExponent = getDisplayDenomExponentFromValueView(b.balanceView); - const aAmount = fromBaseUnitAmount(getAmount(a.balanceView), aExponent); - const bAmount = fromBaseUnitAmount(getAmount(b.balanceView), bExponent); - - return bAmount.comparedTo(aAmount); -}; - const getAndSetDefaultAssetBalances = async (assets: Metadata[]) => { - const assetBalances = await getBalances(); - - // filter assets that are not available for swap - const filteredAssetBalances = assetBalances - .filter(b => - [assetPatterns.lpNft, assetPatterns.proposalNft, assetPatterns.votingReceipt].every( - pattern => !pattern.matches(getDisplayDenomFromView(b.balanceView)), - ), - ) - .sort(byBalanceDescending); + const balancesResponses = await getSwappableBalancesResponses(); // set initial denom in if there is an available balance - if (filteredAssetBalances[0]) { - useStore.getState().swap.setAssetIn(filteredAssetBalances[0]); + if (balancesResponses[0]) { + useStore.getState().swap.setAssetIn(balancesResponses[0]); useStore.getState().swap.setAssetOut(assets[0]!); } - return filteredAssetBalances; + return balancesResponses; }; const fetchMetadataForSwap = async (swap: SwapRecord): Promise => { diff --git a/apps/minifront/src/components/swap/unclaimed-swaps.tsx b/apps/minifront/src/components/swap/swap/unclaimed-swaps.tsx similarity index 95% rename from apps/minifront/src/components/swap/unclaimed-swaps.tsx rename to apps/minifront/src/components/swap/swap/unclaimed-swaps.tsx index b63b627839..95478e8c30 100644 --- a/apps/minifront/src/components/swap/unclaimed-swaps.tsx +++ b/apps/minifront/src/components/swap/swap/unclaimed-swaps.tsx @@ -3,8 +3,8 @@ import { Card } from '@penumbra-zone/ui/components/ui/card'; import { useLoaderData, useRevalidator } from 'react-router-dom'; import { SwapLoaderResponse, UnclaimedSwapsWithMetadata } from './swap-loader'; import { AssetIcon } from '@penumbra-zone/ui/components/ui/tx/view/asset-icon'; -import { useStore } from '../../state'; -import { unclaimedSwapsSelector } from '../../state/unclaimed-swaps'; +import { useStore } from '../../../state'; +import { unclaimedSwapsSelector } from '../../../state/unclaimed-swaps'; import { getSwapRecordCommitment } from '@penumbra-zone/getters/swap-record'; import { uint8ArrayToBase64 } from '@penumbra-zone/types/base64'; diff --git a/apps/minifront/src/state/dutch-auction/assemble-request.ts b/apps/minifront/src/state/dutch-auction/assemble-request.ts new file mode 100644 index 0000000000..aeaf8d733f --- /dev/null +++ b/apps/minifront/src/state/dutch-auction/assemble-request.ts @@ -0,0 +1,52 @@ +import { TransactionPlannerRequest } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; +import { BLOCKS_PER_MINUTE, DURATION_IN_BLOCKS, STEP_COUNT } from './constants'; +import { getAssetIdFromValueView } from '@penumbra-zone/getters/value-view'; +import { getAssetId } from '@penumbra-zone/getters/metadata'; +import { DutchAuctionSlice } from '.'; +import { viewClient } from '../../clients'; +import { fromString } from '@penumbra-zone/types/amount'; + +/** + * The start height of an auction must be, at minimum, the current block height. + * Since the transaction may take a while to build, and the user may take a + * while to approve it, we need to add some buffer time to the start height. + * Roughly a minute seems appropriate. + */ +const getStartHeight = (fullSyncHeight: bigint) => fullSyncHeight + BLOCKS_PER_MINUTE; + +export const assembleRequest = async ({ + amount: amountAsString, + assetIn, + assetOut, + minOutput, + maxOutput, + duration, +}: DutchAuctionSlice): Promise => { + const assetId = getAssetIdFromValueView(assetIn?.balanceView); + const outputId = getAssetId(assetOut); + const amount = fromString(amountAsString); + + const { fullSyncHeight } = await viewClient.status({}); + + const startHeight = getStartHeight(fullSyncHeight); + const endHeight = startHeight + DURATION_IN_BLOCKS.get(duration)!; + + return new TransactionPlannerRequest({ + dutchAuctionScheduleActions: [ + { + description: { + input: { + amount, + assetId, + }, + outputId, + startHeight, + endHeight, + stepCount: STEP_COUNT, + minOutput: fromString(minOutput), + maxOutput: fromString(maxOutput), + }, + }, + ], + }); +}; diff --git a/apps/minifront/src/state/dutch-auction/constants.ts b/apps/minifront/src/state/dutch-auction/constants.ts new file mode 100644 index 0000000000..a2bbe064d6 --- /dev/null +++ b/apps/minifront/src/state/dutch-auction/constants.ts @@ -0,0 +1,26 @@ +export const DURATION_OPTIONS = ['10min', '30min', '1h', '2h', '6h', '12h', '24h', '48h'] as const; +export type DurationOption = (typeof DURATION_OPTIONS)[number]; + +/** + * Blocks are ~5 seconds long (so, 12 blocks/minute), and the minimum duration + * for an auction is 10 minutes (so, 120 blocks). All other auction durations + * are even multiples of 10 minutes. So we'll set the step count to 120, so that + * the sub-auctions can divide evenly into the number of intervening blocks. + */ +export const STEP_COUNT = 120n; + +const APPROX_BLOCK_DURATION_MS = 5_000n; +const MINUTE_MS = 60_000n; +export const BLOCKS_PER_MINUTE = MINUTE_MS / APPROX_BLOCK_DURATION_MS; +const BLOCKS_PER_HOUR = BLOCKS_PER_MINUTE * 60n; + +export const DURATION_IN_BLOCKS = new Map([ + ['10min', 10n * BLOCKS_PER_MINUTE], + ['30min', 30n * BLOCKS_PER_MINUTE], + ['1h', BLOCKS_PER_HOUR], + ['2h', 2n * BLOCKS_PER_HOUR], + ['6h', 6n * BLOCKS_PER_HOUR], + ['12h', 12n * BLOCKS_PER_HOUR], + ['24h', 24n * BLOCKS_PER_HOUR], + ['48h', 48n * BLOCKS_PER_HOUR], +]); diff --git a/apps/minifront/src/state/dutch-auction/index.ts b/apps/minifront/src/state/dutch-auction/index.ts new file mode 100644 index 0000000000..44da4f177b --- /dev/null +++ b/apps/minifront/src/state/dutch-auction/index.ts @@ -0,0 +1,94 @@ +import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; +import { SliceCreator } from '..'; +import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; +import { planBuildBroadcast } from '../helpers'; +import { assembleRequest } from './assemble-request'; +import { DurationOption } from './constants'; + +export interface DutchAuctionSlice { + balancesResponses: BalancesResponse[]; + setBalancesResponses: (balancesResponses: BalancesResponse[]) => void; + assetIn?: BalancesResponse; + setAssetIn: (assetIn: BalancesResponse) => void; + assetOut?: Metadata; + setAssetOut: (assetOut: Metadata) => void; + amount: string; + setAmount: (amount: string) => void; + duration: DurationOption; + setDuration: (duration: DurationOption) => void; + minOutput: string; + setMinOutput: (minOutput: string) => void; + maxOutput: string; + setMaxOutput: (maxOutput: string) => void; + onSubmit: () => Promise; + txInProgress: boolean; +} + +export const createDutchAuctionSlice = (): SliceCreator => (set, get) => ({ + balancesResponses: [], + setBalancesResponses: balancesResponses => { + set(state => { + state.dutchAuction.balancesResponses = balancesResponses; + }); + }, + + assetIn: undefined, + setAssetIn: assetIn => { + set(state => { + state.dutchAuction.assetIn = assetIn; + }); + }, + + assetOut: undefined, + setAssetOut: assetOut => { + set(state => { + state.dutchAuction.assetOut = assetOut; + }); + }, + + amount: '', + setAmount: amount => { + set(state => { + state.dutchAuction.amount = amount; + }); + }, + + duration: '10min', + setDuration: duration => { + set(state => { + state.dutchAuction.duration = duration; + }); + }, + + minOutput: '1', + setMinOutput: minOutput => { + set(state => { + state.dutchAuction.minOutput = minOutput; + }); + }, + maxOutput: '1000', + setMaxOutput: maxOutput => { + set(state => { + state.dutchAuction.maxOutput = maxOutput; + }); + }, + + onSubmit: async () => { + set(state => { + state.dutchAuction.txInProgress = true; + }); + + try { + const req = await assembleRequest(get().dutchAuction); + await planBuildBroadcast('dutchAuctionSchedule', req); + + get().dutchAuction.setAmount(''); + } finally { + set(state => { + state.dutchAuction.txInProgress = false; + }); + } + }, + + txInProgress: false, +}); diff --git a/apps/minifront/src/state/index.ts b/apps/minifront/src/state/index.ts index a975e71358..f97052072d 100644 --- a/apps/minifront/src/state/index.ts +++ b/apps/minifront/src/state/index.ts @@ -1,6 +1,7 @@ import { create, StateCreator } from 'zustand'; import { enableMapSet } from 'immer'; import { immer } from 'zustand/middleware/immer'; +import { createDutchAuctionSlice, DutchAuctionSlice } from './dutch-auction'; import { createSwapSlice, SwapSlice } from './swap'; import { createIbcOutSlice, IbcOutSlice } from './ibc-out'; import { createSendSlice, SendSlice } from './send'; @@ -19,6 +20,7 @@ enableMapSet(); export interface AllSlices { ibcIn: IbcInSlice; ibcOut: IbcOutSlice; + dutchAuction: DutchAuctionSlice; send: SendSlice; staking: StakingSlice; swap: SwapSlice; @@ -37,6 +39,7 @@ export const initializeStore = () => { return immer((setState, getState: () => AllSlices, store) => ({ ibcIn: createIbcInSlice()(setState, getState, store), ibcOut: createIbcOutSlice()(setState, getState, store), + dutchAuction: createDutchAuctionSlice()(setState, getState, store), send: createSendSlice()(setState, getState, store), staking: createStakingSlice()(setState, getState, store), swap: createSwapSlice()(setState, getState, store), diff --git a/packages/constants/src/assets.test.ts b/packages/constants/src/assets.test.ts index dcb11a6997..12bc3168dd 100644 --- a/packages/constants/src/assets.test.ts +++ b/packages/constants/src/assets.test.ts @@ -2,7 +2,24 @@ import { describe, expect, it } from 'vitest'; import { assetPatterns, RegexMatcher } from './assets'; describe('assetPatterns', () => { - describe('lpNftPattern', () => { + describe('auctionNft', () => { + it('matches when a string is a valid auction NFT', () => { + expect(assetPatterns.auctionNft.matches('auctionnft_0_pauctid1abc123')).toBe(true); + }); + + it('matches when a string contains, but does not begin with, a valid auction NFT', () => { + expect( + assetPatterns.auctionNft.matches('ibc-transfer/channel-1234/auctionnft_0_pauctid1abc123'), + ).toBe(false); + }); + + it('captures the capture groups correctly', () => { + const result = assetPatterns.auctionNft.capture('auctionnft_0_pauctid1abc123'); + expect(result).toEqual({ auctionId: 'pauctid1abc123', seqNum: '0' }); + }); + }); + + describe('lpNft', () => { it('matches when a string begins with `lpnft_`', () => { expect(assetPatterns.lpNft.matches('lpnft_abc123')).toBe(true); }); @@ -12,7 +29,7 @@ describe('assetPatterns', () => { }); }); - describe('delegationTokenPattern', () => { + describe('delegationToken', () => { it('matches when a string is a valid delegation token name', () => { expect(assetPatterns.delegationToken.matches('delegation_penumbravalid1abc123')).toBe(true); }); @@ -26,7 +43,7 @@ describe('assetPatterns', () => { }); }); - describe('proposalNftPattern', () => { + describe('proposalNft', () => { it('matches when a string begins with `proposal_`', () => { expect(assetPatterns.proposalNft.matches('proposal_abc123')).toBe(true); }); @@ -38,7 +55,7 @@ describe('assetPatterns', () => { }); }); - describe('unbondingTokenPattern', () => { + describe('unbondingToken', () => { it('matches when a string is a valid unbonding token name', () => { expect( assetPatterns.unbondingToken.matches('unbonding_start_at_1_penumbravalid1abc123'), @@ -61,7 +78,7 @@ describe('assetPatterns', () => { }); }); - describe('votingReceiptPattern', () => { + describe('votingReceipt', () => { it('matches when a string begins with `voted_on_`', () => { expect(assetPatterns.votingReceipt.matches('voted_on_abc123')).toBe(true); }); diff --git a/packages/constants/src/assets.ts b/packages/constants/src/assets.ts index 26620b6a38..06906deb75 100644 --- a/packages/constants/src/assets.ts +++ b/packages/constants/src/assets.ts @@ -5,6 +5,11 @@ export const PRICE_RELEVANCE_THRESHOLDS = { default: 200, }; +export interface AuctionNftCaptureGroups { + seqNum: string; + auctionId: string; +} + export interface IbcCaptureGroups { channel: string; denom: string; @@ -22,6 +27,7 @@ export interface UnbondingCaptureGroups { } export interface AssetPatterns { + auctionNft: RegexMatcher; lpNft: RegexMatcher; delegationToken: RegexMatcher; proposalNft: RegexMatcher; @@ -62,6 +68,9 @@ export class RegexMatcher { * https://github.com/penumbra-zone/penumbra/blob/main/crates/core/asset/src/asset/registry.rs */ export const assetPatterns: AssetPatterns = { + auctionNft: new RegexMatcher( + /^auctionnft_(?[0-9]+)_(?pauctid1[a-zA-HJ-NP-Z0-9]+)$/, + ), lpNft: new RegexMatcher(/^lpnft_/), delegationToken: new RegexMatcher( /^delegation_(?penumbravalid1(?[a-zA-HJ-NP-Z0-9]+))$/, diff --git a/packages/perspective/plan/view-action-plan.ts b/packages/perspective/plan/view-action-plan.ts index 86e6a67d96..74e9a345bc 100644 --- a/packages/perspective/plan/view-action-plan.ts +++ b/packages/perspective/plan/view-action-plan.ts @@ -250,6 +250,13 @@ export const viewActionPlan = }, }); + case 'actionDutchAuctionSchedule': + case 'actionDutchAuctionEnd': + case 'actionDutchAuctionWithdraw': + return new ActionView({ + actionView: actionPlan.action, + }); + case undefined: throw new Error('No action case in action plan'); default: diff --git a/packages/perspective/transaction/classification.ts b/packages/perspective/transaction/classification.ts index 44e7ecbc8a..4e2cc046e8 100644 --- a/packages/perspective/transaction/classification.ts +++ b/packages/perspective/transaction/classification.ts @@ -20,4 +20,6 @@ export type TransactionClassification = /** The transaction contains an `undelegateClaim` action. */ | 'undelegateClaim' /** The transaction contains an `ics20Withdrawal` action. */ - | 'ics20Withdrawal'; + | 'ics20Withdrawal' + /** The transaction contains an `actionDutchAuctionSchedule` action. */ + | 'dutchAuctionSchedule'; diff --git a/packages/perspective/transaction/classify.test.ts b/packages/perspective/transaction/classify.test.ts index c6c9577dce..1d74240541 100644 --- a/packages/perspective/transaction/classify.test.ts +++ b/packages/perspective/transaction/classify.test.ts @@ -317,6 +317,35 @@ describe('classifyTransaction()', () => { expect(classifyTransaction(transactionView)).toBe('undelegateClaim'); }); + it('returns `dutchAuctionSchedule` for transactions with an `actionDutchAuctionSchedule` action', () => { + const transactionView = new TransactionView({ + bodyView: { + actionViews: [ + { + actionView: { + case: 'actionDutchAuctionSchedule', + value: {}, + }, + }, + { + actionView: { + case: 'spend', + value: {}, + }, + }, + { + actionView: { + case: 'output', + value: {}, + }, + }, + ], + }, + }); + + expect(classifyTransaction(transactionView)).toBe('dutchAuctionSchedule'); + }); + it("returns `unknown` for transactions that don't fit the above categories", () => { const transactionView = new TransactionView({ bodyView: { diff --git a/packages/perspective/transaction/classify.ts b/packages/perspective/transaction/classify.ts index 3eea0182af..46886d7142 100644 --- a/packages/perspective/transaction/classify.ts +++ b/packages/perspective/transaction/classify.ts @@ -7,14 +7,15 @@ export const classifyTransaction = (txv?: TransactionView): TransactionClassific return 'unknown'; } - if (txv.bodyView?.actionViews.some(a => a.actionView.case === 'swap')) return 'swap'; - if (txv.bodyView?.actionViews.some(a => a.actionView.case === 'swapClaim')) return 'swapClaim'; - if (txv.bodyView?.actionViews.some(a => a.actionView.case === 'delegate')) return 'delegate'; - if (txv.bodyView?.actionViews.some(a => a.actionView.case === 'undelegate')) return 'undelegate'; - if (txv.bodyView?.actionViews.some(a => a.actionView.case === 'undelegateClaim')) - return 'undelegateClaim'; - if (txv.bodyView?.actionViews.some(a => a.actionView.case === 'ics20Withdrawal')) - return 'ics20Withdrawal'; + const allActionCases = new Set(txv.bodyView?.actionViews.map(a => a.actionView.case)); + + if (allActionCases.has('swap')) return 'swap'; + if (allActionCases.has('swapClaim')) return 'swapClaim'; + if (allActionCases.has('delegate')) return 'delegate'; + if (allActionCases.has('undelegate')) return 'undelegate'; + if (allActionCases.has('undelegateClaim')) return 'undelegateClaim'; + if (allActionCases.has('ics20Withdrawal')) return 'ics20Withdrawal'; + if (allActionCases.has('actionDutchAuctionSchedule')) return 'dutchAuctionSchedule'; const hasOpaqueSpend = txv.bodyView?.actionViews.some( a => a.actionView.case === 'spend' && a.actionView.value.spendView.case === 'opaque', @@ -88,6 +89,7 @@ export const TRANSACTION_LABEL_BY_CLASSIFICATION: Record diff --git a/packages/types/src/amount.test.ts b/packages/types/src/amount.test.ts index 4d382544d0..8290b0e9ba 100644 --- a/packages/types/src/amount.test.ts +++ b/packages/types/src/amount.test.ts @@ -4,6 +4,7 @@ import { divideAmounts, formatNumber, fromBaseUnitAmount, + fromString, fromValueView, isZero, joinLoHiAmount, @@ -253,3 +254,12 @@ describe('formatNumber', () => { expect(formatNumber(-123.456, { precision: 2 })).toBe('-123.46'); }); }); + +describe('fromString', () => { + it('converts a string to an amount', () => { + const result = fromString('123456'); + const expected = new Amount({ hi: 0n, lo: 123456n }); + + expect(result.equals(expected)).toBe(true); + }); +}); diff --git a/packages/types/src/amount.ts b/packages/types/src/amount.ts index 1e116b243b..f21ee197e5 100644 --- a/packages/types/src/amount.ts +++ b/packages/types/src/amount.ts @@ -23,6 +23,8 @@ export const fromValueView = ({ amount, metadata }: ValueView_KnownAssetId): Big return fromBaseUnitAmount(amount, getDisplayDenomExponent(metadata)); }; +export const fromString = (amount: string): Amount => new Amount(splitLoHi(BigInt(amount))); + export const addAmounts = (a: Amount, b: Amount): Amount => { const joined = joinLoHiAmount(a) + joinLoHiAmount(b); const { lo, hi } = splitLoHi(joined); diff --git a/packages/ui/components/ui/slider.tsx b/packages/ui/components/ui/slider.tsx new file mode 100644 index 0000000000..15e7b1feb8 --- /dev/null +++ b/packages/ui/components/ui/slider.tsx @@ -0,0 +1,30 @@ +'use client'; + +import * as SliderPrimitive from '@radix-ui/react-slider'; + +const Slider = (props: { + min?: number; + max?: number; + step?: number; + value: number[]; + onValueChange: (value: number[]) => void; +}) => ( + + + + + {Array(props.value.length) + .fill(null) + .map((_, index) => ( + + ))} + +); + +export { Slider }; diff --git a/packages/ui/components/ui/tx/view/action-dutch-auction-schedule.tsx b/packages/ui/components/ui/tx/view/action-dutch-auction-schedule.tsx new file mode 100644 index 0000000000..99ade61aa4 --- /dev/null +++ b/packages/ui/components/ui/tx/view/action-dutch-auction-schedule.tsx @@ -0,0 +1,63 @@ +import { ActionDutchAuctionSchedule } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/auction/v1alpha1/auction_pb'; +import { ViewBox } from './viewbox'; +import { ActionDetails } from './action-details'; +import { + AssetId, + ValueView, +} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; +import { ValueViewComponent } from './value'; +import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1/num_pb'; + +const getValueView = (amount?: Amount, assetId?: AssetId) => + new ValueView({ + valueView: { + case: 'knownAssetId', + value: { + amount, + metadata: { + penumbraAssetId: assetId, + }, + }, + }, + }); + +export const ActionDutchAuctionScheduleComponent = ({ + value, +}: { + value: ActionDutchAuctionSchedule; +}) => { + const input = getValueView(value.description?.input?.amount, value.description?.input?.assetId); + const maxOutput = getValueView(value.description?.maxOutput, value.description?.outputId); + const minOutput = getValueView(value.description?.minOutput, value.description?.outputId); + + return ( + + + + + + +
+
+ Max: + +
+
+ Min: + +
+
+
+ + + Height {value.description?.startHeight.toString()} to{' '} + {value.description?.endHeight.toString()} + + + } + /> + ); +}; diff --git a/packages/ui/components/ui/tx/view/action-view.tsx b/packages/ui/components/ui/tx/view/action-view.tsx index e7ead2e146..bb54cf08a7 100644 --- a/packages/ui/components/ui/tx/view/action-view.tsx +++ b/packages/ui/components/ui/tx/view/action-view.tsx @@ -8,6 +8,7 @@ import { UndelegateClaimComponent } from './undelegate-claim'; import { Ics20WithdrawalComponent } from './isc20-withdrawal'; import { UnimplementedView } from './unimplemented-view'; import { SwapViewComponent } from './swap'; +import { ActionDutchAuctionScheduleComponent } from './action-dutch-auction-schedule'; const CASE_TO_LABEL: Record = { daoDeposit: 'DAO Deposit', @@ -67,6 +68,9 @@ export const ActionViewComponent = ({ av: { actionView } }: { av: ActionView }) case 'undelegateClaim': return ; + case 'actionDutchAuctionSchedule': + return ; + case 'validatorDefinition': return ; diff --git a/packages/ui/package.json b/packages/ui/package.json index c47d95e614..6b7052fbb8 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -28,6 +28,7 @@ "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65644311c4..b522c8daba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -956,6 +956,9 @@ importers: '@radix-ui/react-select': specifier: ^2.0.0 version: 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-slider': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-slot': specifier: ^1.0.2 version: 1.0.2(@types/react@18.3.1)(react@18.3.1) @@ -7194,6 +7197,37 @@ packages: react-remove-scroll: 2.5.5(@types/react@18.3.1)(react@18.3.1) dev: false + /@radix-ui/react-slider@1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-NKs15MJylfzVsCagVSWKhGGLNR1W9qWs+HtgbmjjVUB3B9+lb3PYoXxVju3kOrpf0VKyVCtZp+iTwVoqpa1Chw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.5 + '@radix-ui/number': 1.0.1 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-context': 1.0.1(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-direction': 1.0.1(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-use-previous': 1.0.1(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-use-size': 1.0.1(@types/react@18.3.1)(react@18.3.1) + '@types/react': 18.3.1 + '@types/react-dom': 18.3.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /@radix-ui/react-slot@1.0.0(react@18.3.1): resolution: {integrity: sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==} peerDependencies: From fc29d9ba0aa4bdd4508ca6ef09352f650da99547 Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Tue, 30 Apr 2024 16:35:23 -0700 Subject: [PATCH 02/16] Add more docs to Slider --- packages/ui/components/ui/slider.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/ui/components/ui/slider.tsx b/packages/ui/components/ui/slider.tsx index 15e7b1feb8..20fab40d84 100644 --- a/packages/ui/components/ui/slider.tsx +++ b/packages/ui/components/ui/slider.tsx @@ -2,10 +2,30 @@ import * as SliderPrimitive from '@radix-ui/react-slider'; +/** + * Renders a draggable range slider: + * |---o-------------| + * + * @example + * ```tsx + * + * ``` + */ const Slider = (props: { min?: number; max?: number; + /** The step size to count by */ step?: number; + /** + * Note that this is an array. You can pass more than one value to have + * multiple draggable points on the slider. + */ value: number[]; onValueChange: (value: number[]) => void; }) => ( From d5c5e4f170c603150659c7232edeff23806647a0 Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Tue, 30 Apr 2024 16:45:13 -0700 Subject: [PATCH 03/16] Fix metadata --- apps/minifront/src/components/metadata/content.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/minifront/src/components/metadata/content.ts b/apps/minifront/src/components/metadata/content.ts index b0c9f9b2f2..08c4eee014 100644 --- a/apps/minifront/src/components/metadata/content.ts +++ b/apps/minifront/src/components/metadata/content.ts @@ -37,15 +37,15 @@ export const metadata: Record = { }, [PagePath.SWAP]: { title: 'Penumbra | Swap', - description: eduPanelContent[EduPanel.TEMP_FILLER], + description: eduPanelContent[EduPanel.SWAP], }, [PagePath.SWAP_AUCTION]: { title: 'Penumbra | Auction', - description: eduPanelContent[EduPanel.TEMP_FILLER], + description: eduPanelContent[EduPanel.SWAP_AUCTION], }, [PagePath.STAKING]: { title: 'Penumbra | Staking', - description: eduPanelContent[EduPanel.TEMP_FILLER], + description: eduPanelContent[EduPanel.STAKING], }, [PagePath.TRANSACTION_DETAILS]: { title: 'Penumbra | Transaction', From a070e92ee27c349fda23622c38c2aa0f713fffd4 Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Tue, 30 Apr 2024 17:01:31 -0700 Subject: [PATCH 04/16] Rename Prices -> Price --- .../components/swap/dutch-auction/dutch-auction-form.tsx | 4 ++-- .../components/swap/dutch-auction/{prices.tsx => price.tsx} | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) rename apps/minifront/src/components/swap/dutch-auction/{prices.tsx => price.tsx} (94%) diff --git a/apps/minifront/src/components/swap/dutch-auction/dutch-auction-form.tsx b/apps/minifront/src/components/swap/dutch-auction/dutch-auction-form.tsx index 427021faa4..1fb4ce5e01 100644 --- a/apps/minifront/src/components/swap/dutch-auction/dutch-auction-form.tsx +++ b/apps/minifront/src/components/swap/dutch-auction/dutch-auction-form.tsx @@ -4,7 +4,7 @@ import { useStoreShallow } from '../../../utils/use-store-shallow'; import { InputBlock } from '../../shared/input-block'; import InputToken from '../../shared/input-token'; import { DurationSlider } from './duration-slider'; -import { Prices } from './prices'; +import { Price } from './price'; const dutchAuctionFormSelector = (state: AllSlices) => ({ balances: state.dutchAuction.balancesResponses, @@ -49,7 +49,7 @@ export const DutchAuctionForm = () => {
- +
diff --git a/apps/minifront/src/components/swap/dutch-auction/prices.tsx b/apps/minifront/src/components/swap/dutch-auction/price.tsx similarity index 94% rename from apps/minifront/src/components/swap/dutch-auction/prices.tsx rename to apps/minifront/src/components/swap/dutch-auction/price.tsx index 6ff8c82b14..98b5dde94c 100644 --- a/apps/minifront/src/components/swap/dutch-auction/prices.tsx +++ b/apps/minifront/src/components/swap/dutch-auction/price.tsx @@ -6,7 +6,7 @@ import { getAssetIdFromValueView } from '@penumbra-zone/getters/value-view'; import { useLoaderData } from 'react-router-dom'; import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; -const pricesSelector = (state: AllSlices) => ({ +const priceSelector = (state: AllSlices) => ({ assetIn: state.dutchAuction.assetIn, assetOut: state.dutchAuction.assetOut, setAssetOut: state.dutchAuction.setAssetOut, @@ -16,9 +16,9 @@ const pricesSelector = (state: AllSlices) => ({ setMaxOutput: state.dutchAuction.setMaxOutput, }); -export const Prices = () => { +export const Price = () => { const { minOutput, setMinOutput, maxOutput, setMaxOutput, assetIn, assetOut, setAssetOut } = - useStoreShallow(pricesSelector); + useStoreShallow(priceSelector); const assetInId = getAssetIdFromValueView(assetIn?.balanceView); const assets = useLoaderData() as Metadata[]; From d769b9e52ef556b664dbf7c656bf5073f306f4b0 Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Tue, 30 Apr 2024 17:48:51 -0700 Subject: [PATCH 05/16] Fix test name --- packages/constants/src/assets.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/constants/src/assets.test.ts b/packages/constants/src/assets.test.ts index 12bc3168dd..2148233370 100644 --- a/packages/constants/src/assets.test.ts +++ b/packages/constants/src/assets.test.ts @@ -7,7 +7,7 @@ describe('assetPatterns', () => { expect(assetPatterns.auctionNft.matches('auctionnft_0_pauctid1abc123')).toBe(true); }); - it('matches when a string contains, but does not begin with, a valid auction NFT', () => { + it('does not match when a string contains, but does not begin with, a valid auction NFT', () => { expect( assetPatterns.auctionNft.matches('ibc-transfer/channel-1234/auctionnft_0_pauctid1abc123'), ).toBe(false); From 4dae4fe013ed4a12e97ca43bfe52e20733b43220 Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Tue, 30 Apr 2024 18:03:00 -0700 Subject: [PATCH 06/16] Create first tests for assembleRequest --- .../dutch-auction/assemble-request.test.ts | 61 +++++++++++++++++++ .../state/dutch-auction/assemble-request.ts | 5 +- 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 apps/minifront/src/state/dutch-auction/assemble-request.test.ts diff --git a/apps/minifront/src/state/dutch-auction/assemble-request.test.ts b/apps/minifront/src/state/dutch-auction/assemble-request.test.ts new file mode 100644 index 0000000000..7f95049f8a --- /dev/null +++ b/apps/minifront/src/state/dutch-auction/assemble-request.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest'; +import { assembleRequest } from './assemble-request'; +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'; +import { BLOCKS_PER_MINUTE, DURATION_IN_BLOCKS } from './constants'; + +const MOCK_START_HEIGHT = vi.hoisted(() => 1234n); + +const mockViewClient = vi.hoisted(() => ({ + status: () => Promise.resolve({ fullSyncHeight: MOCK_START_HEIGHT }), +})); + +vi.mock('../../clients', () => ({ + viewClient: mockViewClient, +})); + +const metadata = new Metadata({ + penumbraAssetId: {}, +}); + +const balancesResponse = new BalancesResponse({ + balanceView: { + valueView: { + case: 'knownAssetId', + value: { + metadata, + }, + }, + }, +}); + +const ARGS: Parameters[0] = { + amount: '123', + duration: '10min', + minOutput: '1', + maxOutput: '1000', + assetIn: balancesResponse, + assetOut: metadata, +}; + +describe('assembleRequest()', () => { + it('correctly converts durations to block heights', async () => { + const req = await assembleRequest({ ...ARGS, duration: '10min' }); + + expect(req.dutchAuctionScheduleActions[0]!.description!.startHeight).toBe( + MOCK_START_HEIGHT + BLOCKS_PER_MINUTE, + ); + expect(req.dutchAuctionScheduleActions[0]!.description!.endHeight).toBe( + MOCK_START_HEIGHT + BLOCKS_PER_MINUTE + DURATION_IN_BLOCKS.get('10min')!, + ); + + const req2 = await assembleRequest({ ...ARGS, duration: '48h' }); + + expect(req2.dutchAuctionScheduleActions[0]!.description!.startHeight).toBe( + MOCK_START_HEIGHT + BLOCKS_PER_MINUTE, + ); + expect(req2.dutchAuctionScheduleActions[0]!.description!.endHeight).toBe( + MOCK_START_HEIGHT + BLOCKS_PER_MINUTE + DURATION_IN_BLOCKS.get('48h')!, + ); + }); +}); diff --git a/apps/minifront/src/state/dutch-auction/assemble-request.ts b/apps/minifront/src/state/dutch-auction/assemble-request.ts index aeaf8d733f..455bfc2254 100644 --- a/apps/minifront/src/state/dutch-auction/assemble-request.ts +++ b/apps/minifront/src/state/dutch-auction/assemble-request.ts @@ -21,7 +21,10 @@ export const assembleRequest = async ({ minOutput, maxOutput, duration, -}: DutchAuctionSlice): Promise => { +}: Pick< + DutchAuctionSlice, + 'amount' | 'assetIn' | 'assetOut' | 'minOutput' | 'maxOutput' | 'duration' +>): Promise => { const assetId = getAssetIdFromValueView(assetIn?.balanceView); const outputId = getAssetId(assetOut); const amount = fromString(amountAsString); From 38445a2fc2525b6ecaaeb08ff3998e34bd53a23f Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Tue, 30 Apr 2024 18:12:06 -0700 Subject: [PATCH 07/16] Add one more test --- .../src/state/dutch-auction/assemble-request.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/minifront/src/state/dutch-auction/assemble-request.test.ts b/apps/minifront/src/state/dutch-auction/assemble-request.test.ts index 7f95049f8a..7f11cd85a6 100644 --- a/apps/minifront/src/state/dutch-auction/assemble-request.test.ts +++ b/apps/minifront/src/state/dutch-auction/assemble-request.test.ts @@ -58,4 +58,10 @@ describe('assembleRequest()', () => { MOCK_START_HEIGHT + BLOCKS_PER_MINUTE + DURATION_IN_BLOCKS.get('48h')!, ); }); + + it('uses a step count of 120', async () => { + const req = await assembleRequest(ARGS); + + expect(req.dutchAuctionScheduleActions[0]!.description!.stepCount).toBe(120n); + }); }); From 4c290dc148763dcc8ad307007701fb45016b39e8 Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Tue, 30 Apr 2024 18:14:24 -0700 Subject: [PATCH 08/16] Tweak Price layout --- .../components/swap/dutch-auction/price.tsx | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/minifront/src/components/swap/dutch-auction/price.tsx b/apps/minifront/src/components/swap/dutch-auction/price.tsx index 98b5dde94c..252478caa4 100644 --- a/apps/minifront/src/components/swap/dutch-auction/price.tsx +++ b/apps/minifront/src/components/swap/dutch-auction/price.tsx @@ -24,31 +24,31 @@ export const Price = () => { return (
-
- Min: - setMinOutput(e.target.value)} - type='number' - inputMode='numeric' - className='grow' - /> -
+
+
+ Min: + setMinOutput(e.target.value)} + type='number' + inputMode='numeric' + /> +
-
- Max: - setMaxOutput(e.target.value)} - type='number' - inputMode='numeric' - className='grow text-right' - /> +
+ Max: + setMaxOutput(e.target.value)} + type='number' + inputMode='numeric' + /> +
From afc688c40f2b3aa8da6636e2d039c65039b9b2f8 Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Tue, 30 Apr 2024 18:49:57 -0700 Subject: [PATCH 09/16] Add auction to swap sublinks --- apps/minifront/src/components/header/constants.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/minifront/src/components/header/constants.tsx b/apps/minifront/src/components/header/constants.tsx index 8420069f26..bdf9656496 100644 --- a/apps/minifront/src/components/header/constants.tsx +++ b/apps/minifront/src/components/header/constants.tsx @@ -29,6 +29,7 @@ export const headerLinks: HeaderLink[] = [ href: PagePath.SWAP, label: 'Swap', active: true, + subLinks: [PagePath.SWAP_AUCTION], mobileIcon: , }, { From 903d7fd5e89fb73832cf1b347bae0960dc7ef16b Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Tue, 30 Apr 2024 18:52:50 -0700 Subject: [PATCH 10/16] Remove tabs for now --- apps/minifront/src/components/swap/layout.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/minifront/src/components/swap/layout.tsx b/apps/minifront/src/components/swap/layout.tsx index 524b247d4c..470c67ace1 100644 --- a/apps/minifront/src/components/swap/layout.tsx +++ b/apps/minifront/src/components/swap/layout.tsx @@ -12,7 +12,7 @@ const TABS: Tab[] = [ }, { title: 'Auction', - enabled: true, + enabled: false, href: PagePath.SWAP_AUCTION, }, ]; @@ -23,7 +23,8 @@ export const SwapLayout = () => { return (
- + {/** @todo: Remove this conditional when we launch auctions */} + {TABS[1]!.enabled && }
From a97bc1cb8db9b039754dc7a693c68dd72cebbf39 Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Wed, 1 May 2024 12:43:32 -0700 Subject: [PATCH 11/16] Convert to a Record to avoid having to assert presence --- .../dutch-auction/assemble-request.test.ts | 4 ++-- .../state/dutch-auction/assemble-request.ts | 2 +- .../src/state/dutch-auction/constants.ts | 20 +++++++++---------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/minifront/src/state/dutch-auction/assemble-request.test.ts b/apps/minifront/src/state/dutch-auction/assemble-request.test.ts index 7f11cd85a6..8075f90012 100644 --- a/apps/minifront/src/state/dutch-auction/assemble-request.test.ts +++ b/apps/minifront/src/state/dutch-auction/assemble-request.test.ts @@ -46,7 +46,7 @@ describe('assembleRequest()', () => { MOCK_START_HEIGHT + BLOCKS_PER_MINUTE, ); expect(req.dutchAuctionScheduleActions[0]!.description!.endHeight).toBe( - MOCK_START_HEIGHT + BLOCKS_PER_MINUTE + DURATION_IN_BLOCKS.get('10min')!, + MOCK_START_HEIGHT + BLOCKS_PER_MINUTE + DURATION_IN_BLOCKS['10min'], ); const req2 = await assembleRequest({ ...ARGS, duration: '48h' }); @@ -55,7 +55,7 @@ describe('assembleRequest()', () => { MOCK_START_HEIGHT + BLOCKS_PER_MINUTE, ); expect(req2.dutchAuctionScheduleActions[0]!.description!.endHeight).toBe( - MOCK_START_HEIGHT + BLOCKS_PER_MINUTE + DURATION_IN_BLOCKS.get('48h')!, + MOCK_START_HEIGHT + BLOCKS_PER_MINUTE + DURATION_IN_BLOCKS['48h'], ); }); diff --git a/apps/minifront/src/state/dutch-auction/assemble-request.ts b/apps/minifront/src/state/dutch-auction/assemble-request.ts index 455bfc2254..674e80fac3 100644 --- a/apps/minifront/src/state/dutch-auction/assemble-request.ts +++ b/apps/minifront/src/state/dutch-auction/assemble-request.ts @@ -32,7 +32,7 @@ export const assembleRequest = async ({ const { fullSyncHeight } = await viewClient.status({}); const startHeight = getStartHeight(fullSyncHeight); - const endHeight = startHeight + DURATION_IN_BLOCKS.get(duration)!; + const endHeight = startHeight + DURATION_IN_BLOCKS[duration]; return new TransactionPlannerRequest({ dutchAuctionScheduleActions: [ diff --git a/apps/minifront/src/state/dutch-auction/constants.ts b/apps/minifront/src/state/dutch-auction/constants.ts index a2bbe064d6..18353d11c8 100644 --- a/apps/minifront/src/state/dutch-auction/constants.ts +++ b/apps/minifront/src/state/dutch-auction/constants.ts @@ -14,13 +14,13 @@ const MINUTE_MS = 60_000n; export const BLOCKS_PER_MINUTE = MINUTE_MS / APPROX_BLOCK_DURATION_MS; const BLOCKS_PER_HOUR = BLOCKS_PER_MINUTE * 60n; -export const DURATION_IN_BLOCKS = new Map([ - ['10min', 10n * BLOCKS_PER_MINUTE], - ['30min', 30n * BLOCKS_PER_MINUTE], - ['1h', BLOCKS_PER_HOUR], - ['2h', 2n * BLOCKS_PER_HOUR], - ['6h', 6n * BLOCKS_PER_HOUR], - ['12h', 12n * BLOCKS_PER_HOUR], - ['24h', 24n * BLOCKS_PER_HOUR], - ['48h', 48n * BLOCKS_PER_HOUR], -]); +export const DURATION_IN_BLOCKS: Record = { + '10min': 10n * BLOCKS_PER_MINUTE, + '30min': 30n * BLOCKS_PER_MINUTE, + '1h': BLOCKS_PER_HOUR, + '2h': 2n * BLOCKS_PER_HOUR, + '6h': 6n * BLOCKS_PER_HOUR, + '12h': 12n * BLOCKS_PER_HOUR, + '24h': 24n * BLOCKS_PER_HOUR, + '48h': 48n * BLOCKS_PER_HOUR, +}; From d60d4956dc01cefd8a71c6d881f7e10ede4e14f5 Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Wed, 1 May 2024 12:57:44 -0700 Subject: [PATCH 12/16] Change min output --- apps/minifront/src/components/swap/dutch-auction/price.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/minifront/src/components/swap/dutch-auction/price.tsx b/apps/minifront/src/components/swap/dutch-auction/price.tsx index 252478caa4..7ef08fa1c2 100644 --- a/apps/minifront/src/components/swap/dutch-auction/price.tsx +++ b/apps/minifront/src/components/swap/dutch-auction/price.tsx @@ -30,7 +30,7 @@ export const Price = () => { setMinOutput(e.target.value)} type='number' From 01048cacdbcba935b54bb68652067d64413ad701 Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Wed, 1 May 2024 12:58:22 -0700 Subject: [PATCH 13/16] Change to decimal --- apps/minifront/src/components/swap/dutch-auction/price.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/minifront/src/components/swap/dutch-auction/price.tsx b/apps/minifront/src/components/swap/dutch-auction/price.tsx index 7ef08fa1c2..4610feb115 100644 --- a/apps/minifront/src/components/swap/dutch-auction/price.tsx +++ b/apps/minifront/src/components/swap/dutch-auction/price.tsx @@ -34,7 +34,7 @@ export const Price = () => { max={maxOutput} onChange={e => setMinOutput(e.target.value)} type='number' - inputMode='numeric' + inputMode='decimal' />
From 6af8054e4f4b9385b31c932e79c8dde13804b740 Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Wed, 1 May 2024 13:17:58 -0700 Subject: [PATCH 14/16] Align to the right --- .../components/ui/tx/view/action-dutch-auction-schedule.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui/components/ui/tx/view/action-dutch-auction-schedule.tsx b/packages/ui/components/ui/tx/view/action-dutch-auction-schedule.tsx index 99ade61aa4..eae857972d 100644 --- a/packages/ui/components/ui/tx/view/action-dutch-auction-schedule.tsx +++ b/packages/ui/components/ui/tx/view/action-dutch-auction-schedule.tsx @@ -40,13 +40,13 @@ export const ActionDutchAuctionScheduleComponent = ({ -
+
- Max: + Max:
- Min: + Min:
From cc83b97510c773d2382d09f72389269c0800ddf1 Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Wed, 1 May 2024 13:19:14 -0700 Subject: [PATCH 15/16] Handle exponent when processing amounts --- .../dutch-auction/assemble-request.test.ts | 32 +++++++++++++++++++ .../state/dutch-auction/assemble-request.ts | 15 ++++++--- packages/types/src/amount.test.ts | 7 ++++ packages/types/src/amount.ts | 5 +-- 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/apps/minifront/src/state/dutch-auction/assemble-request.test.ts b/apps/minifront/src/state/dutch-auction/assemble-request.test.ts index 8075f90012..7d88c92c53 100644 --- a/apps/minifront/src/state/dutch-auction/assemble-request.test.ts +++ b/apps/minifront/src/state/dutch-auction/assemble-request.test.ts @@ -3,6 +3,7 @@ import { assembleRequest } from './assemble-request'; 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'; import { BLOCKS_PER_MINUTE, DURATION_IN_BLOCKS } from './constants'; +import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1/num_pb'; const MOCK_START_HEIGHT = vi.hoisted(() => 1234n); @@ -15,6 +16,18 @@ vi.mock('../../clients', () => ({ })); const metadata = new Metadata({ + base: 'uasset', + display: 'asset', + denomUnits: [ + { + denom: 'uasset', + exponent: 0, + }, + { + denom: 'asset', + exponent: 6, + }, + ], penumbraAssetId: {}, }); @@ -64,4 +77,23 @@ describe('assembleRequest()', () => { expect(req.dutchAuctionScheduleActions[0]!.description!.stepCount).toBe(120n); }); + + it('correctly parses the input based on the display denom exponent', async () => { + const req = await assembleRequest(ARGS); + + expect(req.dutchAuctionScheduleActions[0]!.description!.input?.amount).toEqual( + new Amount({ hi: 0n, lo: 123_000_000n }), + ); + }); + + it('correctly parses the min/max outputs based on the display denom exponent', async () => { + const req = await assembleRequest(ARGS); + + expect(req.dutchAuctionScheduleActions[0]!.description!.minOutput).toEqual( + new Amount({ hi: 0n, lo: 1_000_000n }), + ); + expect(req.dutchAuctionScheduleActions[0]!.description!.maxOutput).toEqual( + new Amount({ hi: 0n, lo: 1_000_000_000n }), + ); + }); }); diff --git a/apps/minifront/src/state/dutch-auction/assemble-request.ts b/apps/minifront/src/state/dutch-auction/assemble-request.ts index 674e80fac3..e135f1868b 100644 --- a/apps/minifront/src/state/dutch-auction/assemble-request.ts +++ b/apps/minifront/src/state/dutch-auction/assemble-request.ts @@ -1,7 +1,10 @@ import { TransactionPlannerRequest } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb'; import { BLOCKS_PER_MINUTE, DURATION_IN_BLOCKS, STEP_COUNT } from './constants'; -import { getAssetIdFromValueView } from '@penumbra-zone/getters/value-view'; -import { getAssetId } from '@penumbra-zone/getters/metadata'; +import { + getAssetIdFromValueView, + getDisplayDenomExponentFromValueView, +} from '@penumbra-zone/getters/value-view'; +import { getAssetId, getDisplayDenomExponent } from '@penumbra-zone/getters/metadata'; import { DutchAuctionSlice } from '.'; import { viewClient } from '../../clients'; import { fromString } from '@penumbra-zone/types/amount'; @@ -27,7 +30,9 @@ export const assembleRequest = async ({ >): Promise => { const assetId = getAssetIdFromValueView(assetIn?.balanceView); const outputId = getAssetId(assetOut); - const amount = fromString(amountAsString); + const assetInExponent = getDisplayDenomExponentFromValueView(assetIn?.balanceView); + const assetOutExponent = getDisplayDenomExponent(assetOut); + const amount = fromString(amountAsString, assetInExponent); const { fullSyncHeight } = await viewClient.status({}); @@ -46,8 +51,8 @@ export const assembleRequest = async ({ startHeight, endHeight, stepCount: STEP_COUNT, - minOutput: fromString(minOutput), - maxOutput: fromString(maxOutput), + minOutput: fromString(minOutput, assetOutExponent), + maxOutput: fromString(maxOutput, assetOutExponent), }, }, ], diff --git a/packages/types/src/amount.test.ts b/packages/types/src/amount.test.ts index 8290b0e9ba..1a58962035 100644 --- a/packages/types/src/amount.test.ts +++ b/packages/types/src/amount.test.ts @@ -262,4 +262,11 @@ describe('fromString', () => { expect(result.equals(expected)).toBe(true); }); + + it('handles an exponent', () => { + const result = fromString('123.456', 3); + const expected = new Amount({ hi: 0n, lo: 123456n }); + + expect(result.equals(expected)).toBe(true); + }); }); diff --git a/packages/types/src/amount.ts b/packages/types/src/amount.ts index f21ee197e5..5eb28b6715 100644 --- a/packages/types/src/amount.ts +++ b/packages/types/src/amount.ts @@ -1,5 +1,5 @@ import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1/num_pb'; -import { fromBaseUnit, joinLoHi, splitLoHi } from './lo-hi'; +import { fromBaseUnit, joinLoHi, splitLoHi, toBaseUnit } from './lo-hi'; import { BigNumber } from 'bignumber.js'; import { ValueView_KnownAssetId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb'; import { getDisplayDenomExponent } from '@penumbra-zone/getters/metadata'; @@ -23,7 +23,8 @@ export const fromValueView = ({ amount, metadata }: ValueView_KnownAssetId): Big return fromBaseUnitAmount(amount, getDisplayDenomExponent(metadata)); }; -export const fromString = (amount: string): Amount => new Amount(splitLoHi(BigInt(amount))); +export const fromString = (amount: string, exponent = 0): Amount => + new Amount(toBaseUnit(BigNumber(amount), exponent)); export const addAmounts = (a: Amount, b: Amount): Amount => { const joined = joinLoHiAmount(a) + joinLoHiAmount(b); From 16577516105060a61957dbd9c60e21c5a9a72d36 Mon Sep 17 00:00:00 2001 From: Jesse Pinho Date: Wed, 1 May 2024 13:46:32 -0700 Subject: [PATCH 16/16] Add test coverage for constants --- .../src/state/dutch-auction/constants.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 apps/minifront/src/state/dutch-auction/constants.test.ts diff --git a/apps/minifront/src/state/dutch-auction/constants.test.ts b/apps/minifront/src/state/dutch-auction/constants.test.ts new file mode 100644 index 0000000000..0e1c41e27d --- /dev/null +++ b/apps/minifront/src/state/dutch-auction/constants.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'vitest'; +import { DURATION_IN_BLOCKS, STEP_COUNT } from './constants'; + +const isCleanlyDivisible = (numerator: bigint, denominator: bigint): boolean => + Number(numerator) % Number(denominator) === 0; + +/** + * "Why are we testing a constants file?!" Good question! + * + * Each duration for an auction needs to be cleanly divisible by the step count + * so that sub-auctions can be evenly distributed. If an unsuspecting developer + * changes some of the constants in `./constants.ts` in the future, there could + * be durations that aren't cleanly divisible by the step count. So this test + * suite ensures that that case never happens. + */ +describe('DURATION_IN_BLOCKS and STEP_COUNT', () => { + test('every duration option is cleanly divisible by `STEP_COUNT`', () => { + Object.values(DURATION_IN_BLOCKS).forEach(duration => { + expect(isCleanlyDivisible(duration, STEP_COUNT)).toBe(true); + }); + }); +});