Skip to content

Commit d170339

Browse files
authored
Feat/minifront v2 staking (#2646)
## Description of Changes This pull request implements a new "Staking" feature for `minifront-v2`, evolving from the legacy `apps/minifront` implementation and following the latest design specifications for delegation management and validator interaction. **Highlights** • New route `/stake` with comprehensive staking interface – **Your Staking Assets** overview with account selector – **Delegation Tokens** management with validator information – **Available Validators** list with delegation capabilities • Delegation management workflows – Interactive delegate/undelegate dialogs with proper form validation – "Review Prax" button feedback for improved transaction UX (Toasts not yet available in minfront-v2, tbd) – Real-time balance updates and account switching support • Validator display enhancements – Responsive validator rows with proper text truncation – ValueView component integration for accurate amount formatting – Voting power and commission rate display • Account integration & state management – Proper BalancesStore and StakingStore coordination – Real-time data reloading when changing accounts • Portfolio integration – Unbonding token formatting improvements ### Screenshots **Staking Assets Overview:** <img width="1717" height="987" alt="image" src="https://github.com/user-attachments/assets/144213ad-304d-4bec-84ef-a90f2b4d3c84" /> **Delegation Management:** <img width="1717" height="987" alt="image" src="https://github.com/user-attachments/assets/018d174f-da6a-43af-92e8-9991b2e9bd23" /> ## Related Issue Closes #[[2645](#2645)] ## Checklist Before Requesting Review - [x] I have ensured that these changes do **not** break `apps/minifront`. - [x] The staking page UI conforms to design specifications - [x] Account switching properly updates all staking-related data - [x] Delegate/Undelegate workflows function correctly with minimal user feedback (enhanced Toast feedback tbd)
1 parent c675735 commit d170339

File tree

22 files changed

+1855
-45
lines changed

22 files changed

+1855
-45
lines changed

.changeset/deep-regions-call.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'minifront-v2': minor
3+
'@penumbra-zone/ui': patch
4+
---
5+
6+
Implement comprehensive staking page for minifront-v2
7+
8+
- Add complete delegation management with validator selection and amount input
9+
- Implement responsive staking assets overview with account-reactive balance display
10+
- Add delegation tokens section with proper validator info parsing and ValueView integration
11+
- Minimalistic transaction UX via Button content & auto-closing dialog (Toast component integration tbd)
12+
- Enhanced unbonding token formatting for Assetcard on Portfolio Page

apps/minifront-v2/src/app/router.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Portfolio } from '@/pages/portfolio';
44
import { AllTransactionsPage } from '@/pages/portfolio/ui/transactions/all-transactions-page';
55
import { Transfer } from '@/pages/transfer';
66
import { Shielding } from '@/pages/shielding';
7+
import { Stake } from '@/pages/stake';
78
import { NotFoundPage } from '@/pages/not-found';
89
import { Layout } from './layout';
910
import { abortLoader } from '@/shared/lib/abort-loader';
@@ -33,6 +34,10 @@ const routes: RouteObject[] = [
3334
path: PagePath.Shielding,
3435
element: <Shielding />,
3536
},
37+
{
38+
path: PagePath.Stake,
39+
element: <Stake />,
40+
},
3641
{
3742
path: '*',
3843
element: <NotFoundPage />,

apps/minifront-v2/src/pages/portfolio/ui/assets/asset-card/AssetListItem.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ export const AssetListItem = ({ asset }: AssetListItemProps) => {
3434
(asset.originalMetadata &&
3535
assetPatterns.delegationToken.matches(getDisplay.optional(asset.originalMetadata) || ''));
3636

37+
// Check if this is an unbonding token and format accordingly
38+
const isUnbondingToken =
39+
asset.originalMetadata?.symbol?.startsWith('unbondUM(') ||
40+
asset.originalMetadata?.symbol?.startsWith('unbondUMat') ||
41+
(asset.originalMetadata &&
42+
assetPatterns.unbondingToken.matches(getDisplay.optional(asset.originalMetadata) || ''));
43+
3744
// Format display for delegation tokens
3845
let displaySymbol = asset.symbol;
3946
let displayName = asset.name;
@@ -62,6 +69,30 @@ export const AssetListItem = ({ asset }: AssetListItemProps) => {
6269
}
6370

6471
displayName = validatorId ? `Delegated Penumbra (${validatorId})` : 'Delegated Penumbra';
72+
} else if (isUnbondingToken && asset.originalMetadata) {
73+
// Show clean "unbondUM" symbol
74+
displaySymbol = 'unbondUM';
75+
76+
// Extract validator ID and unbonding details for the name
77+
let validatorId = '';
78+
const display = getDisplay.optional(asset.originalMetadata);
79+
if (display) {
80+
const unbondingMatch = assetPatterns.unbondingToken.capture(display);
81+
if (unbondingMatch?.id) {
82+
validatorId = unbondingMatch.id;
83+
}
84+
}
85+
86+
// If we couldn't get from display, try from symbol
87+
if (!validatorId && asset.originalMetadata.symbol?.startsWith('unbondUMat')) {
88+
// Extract from "unbondUMat6146964(9gvk7d09tqurgmy0ef2j3dj8x5nzzt0..."
89+
const match = /unbondUMat\d+\(([^)]+)\)/.exec(asset.originalMetadata.symbol);
90+
if (match?.[1]) {
91+
validatorId = match[1];
92+
}
93+
}
94+
95+
displayName = validatorId ? `Unbonding Penumbra (${validatorId})` : 'Unbonding Penumbra';
6596
}
6697

6798
// Action handlers

apps/minifront-v2/src/pages/shielding/ui/deposit/deposit-form.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -441,16 +441,14 @@ const DepositFormInternal = observer(() => {
441441
{/* Destination Account */}
442442
<div className={lastSectionClasses}>
443443
<Text color={sectionTitleColor}>Destination</Text>
444-
<Density compact>
445-
<AccountSelector
446-
value={depositState.destinationAccount}
447-
onChange={handleAccountChange}
448-
canGoPrevious={depositState.destinationAccount > 0}
449-
canGoNext={true}
450-
getDisplayValue={getAccountDisplayName}
451-
disabled={isFormDisabled}
452-
/>
453-
</Density>
444+
<AccountSelector
445+
value={depositState.destinationAccount}
446+
onChange={handleAccountChange}
447+
canGoPrevious={depositState.destinationAccount > 0}
448+
canGoNext={true}
449+
getDisplayValue={getAccountDisplayName}
450+
disabled={isFormDisabled}
451+
/>
454452
</div>
455453

456454
{/* Error Display */}

apps/minifront-v2/src/pages/shielding/ui/shared/shielding.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,11 @@ export const Shielding = () => {
7878
/>
7979
</div>
8080
</Density>
81-
<div className='flex min-h-48 items-center justify-center'>
81+
<div
82+
className={`flex min-h-48 items-center justify-center ${
83+
activeTab !== 'skip-deposit' ? ' pt-2' : ''
84+
}`}
85+
>
8286
{/* Tab Content */}
8387
{renderTabContent()}
8488
</div>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { Stake } from './ui/stake';
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { observer } from 'mobx-react-lite';
2+
import { Dialog } from '@penumbra-zone/ui/Dialog';
3+
import { Button } from '@penumbra-zone/ui/Button';
4+
import { Text } from '@penumbra-zone/ui/Text';
5+
import { Card } from '@penumbra-zone/ui/Card';
6+
import { Info, CircleX } from 'lucide-react';
7+
import { useStakingStore, useBalancesStore } from '@/shared/stores/store-context';
8+
import { ValidatorRow } from '../validator-row';
9+
import { AssetValueInput } from '@penumbra-zone/ui/AssetValueInput';
10+
import { getMetadataFromBalancesResponse } from '@penumbra-zone/getters/balances-response';
11+
import { BalancesResponse } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb';
12+
import { useState, useEffect, useMemo } from 'react';
13+
import { assetPatterns } from '@penumbra-zone/types/assets';
14+
import { getDisplay } from '@penumbra-zone/getters/metadata';
15+
import { bech32mIdentityKey } from '@penumbra-zone/bech32m/penumbravalid';
16+
import { getIdentityKeyFromValidatorInfo } from '@penumbra-zone/getters/validator-info';
17+
18+
export const DelegateDialog = observer(() => {
19+
const stakingStore = useStakingStore();
20+
const balancesStore = useBalancesStore();
21+
22+
const isOpen = !!stakingStore.action;
23+
const action = stakingStore.action;
24+
const validator = stakingStore.actionValidator;
25+
const amount = stakingStore.amount;
26+
27+
// Local state for selected asset
28+
const [selectedAsset, setSelectedAsset] = useState<BalancesResponse | undefined>(undefined);
29+
30+
// Get validator identity key for filtering delegation tokens
31+
const identityKey = useMemo(() => {
32+
if (!validator) return '';
33+
const key = getIdentityKeyFromValidatorInfo.optional(validator);
34+
return key ? bech32mIdentityKey({ ik: key.ik }) : '';
35+
}, [validator]);
36+
37+
// Get all UM balances from all accounts
38+
const allUmBalances = useMemo(() => {
39+
return balancesStore.balancesByAccount.flatMap(acc =>
40+
acc.balances.filter(balance => {
41+
const metadata = getMetadataFromBalancesResponse.optional?.(balance);
42+
return metadata?.symbol === 'UM';
43+
}),
44+
);
45+
}, [balancesStore.balancesByAccount]);
46+
47+
// Get all delegation balances for this specific validator
48+
const allDelegationBalances = useMemo(() => {
49+
if (!identityKey) return [];
50+
51+
return balancesStore.balancesByAccount.flatMap(acc =>
52+
acc.balances.filter(balance => {
53+
const metadata = getMetadataFromBalancesResponse.optional(balance);
54+
const display = getDisplay.optional(metadata);
55+
if (!display) return false;
56+
57+
const match = assetPatterns.delegationToken.capture(display);
58+
return match?.idKey === identityKey;
59+
}),
60+
);
61+
}, [balancesStore.balancesByAccount, identityKey]);
62+
63+
// Choose which balances to show based on action
64+
const balancesToShow = action === 'delegate' ? allUmBalances : allDelegationBalances;
65+
66+
// Pre-select appropriate balance from current account if available
67+
const currentAccountInitial = useMemo(() => {
68+
const currentAccountBalances =
69+
balancesStore.balancesByAccount.find(acc => acc.account === stakingStore.currentAccount)
70+
?.balances ?? [];
71+
72+
if (action === 'delegate') {
73+
return currentAccountBalances.find(balance => {
74+
const metadata = getMetadataFromBalancesResponse.optional(balance);
75+
return metadata?.symbol === 'UM';
76+
});
77+
} else {
78+
return currentAccountBalances.find(balance => {
79+
const metadata = getMetadataFromBalancesResponse.optional(balance);
80+
const display = getDisplay.optional(metadata);
81+
if (!display) return false;
82+
83+
const match = assetPatterns.delegationToken.capture(display);
84+
return match?.idKey === identityKey;
85+
});
86+
}
87+
}, [balancesStore.balancesByAccount, stakingStore.currentAccount, action, identityKey]);
88+
89+
// Initialize selected asset when dialog opens
90+
useEffect(() => {
91+
if (isOpen && !selectedAsset) {
92+
const initialAsset = currentAccountInitial || balancesToShow[0];
93+
setSelectedAsset(initialAsset);
94+
stakingStore.setSelectedBalancesResponse(initialAsset);
95+
}
96+
}, [isOpen, currentAccountInitial, balancesToShow, selectedAsset, stakingStore]);
97+
98+
// Reset selected asset when dialog closes
99+
useEffect(() => {
100+
if (!isOpen) {
101+
setSelectedAsset(undefined);
102+
stakingStore.setSelectedBalancesResponse(undefined);
103+
}
104+
}, [isOpen, stakingStore]);
105+
106+
// Check if validator has high voting power (>5%)
107+
const votingPower = validator ? stakingStore.getVotingPower(validator) : 0;
108+
const showCautionBanner = votingPower > 5;
109+
110+
const handleSubmit = async () => {
111+
if (!action) return;
112+
113+
try {
114+
if (action === 'delegate') {
115+
await stakingStore.delegate();
116+
} else {
117+
await stakingStore.undelegate();
118+
}
119+
} catch (error) {
120+
console.error(`Failed to ${action}:`, error);
121+
}
122+
};
123+
124+
const handleClose = () => {
125+
stakingStore.closeAction();
126+
};
127+
128+
const handleAssetChange = (asset: BalancesResponse) => {
129+
setSelectedAsset(asset);
130+
stakingStore.setSelectedBalancesResponse(asset);
131+
// Reset amount when asset changes
132+
stakingStore.setAmount('');
133+
};
134+
135+
const isFormValid = !!selectedAsset && amount && parseFloat(amount) > 0;
136+
const actionLabel = action === 'delegate' ? 'Delegate' : 'Undelegate';
137+
138+
// Button text logic for different states
139+
const getButtonText = () => {
140+
if (stakingStore.loading) {
141+
return 'Review Prax';
142+
}
143+
return actionLabel;
144+
};
145+
146+
if (!isOpen || !validator) {
147+
return null;
148+
}
149+
150+
return (
151+
<Dialog isOpen={isOpen} onClose={handleClose}>
152+
<Dialog.Content title={actionLabel}>
153+
<div className='flex flex-col gap-4'>
154+
{/* Caution Banner */}
155+
{showCautionBanner && action === 'delegate' && (
156+
<div className='flex items-center gap-3 rounded-sm bg-caution-light p-3'>
157+
<Info size={22} className='text-caution-dark mt-0.5 flex-shrink-0' />
158+
<Text small color='caution.dark'>
159+
The validator you're delegating to has more than 5% of the current voting power. To
160+
promote decentralization, it's recommended to choose a smaller validator.
161+
</Text>
162+
</div>
163+
)}
164+
{/* Error Message */}
165+
{stakingStore.error && (
166+
<div className='flex items-center gap-3 rounded-sm bg-destructive-light p-3'>
167+
<CircleX size={22} className='text-destructive-dark' />
168+
<Text small color='destructive.dark'>
169+
{stakingStore.error}
170+
</Text>
171+
</div>
172+
)}
173+
174+
{/* Validator Info */}
175+
<div className='flex flex-col rounded-md overflow-hidden gap-1'>
176+
<Card.Section>
177+
<ValidatorRow validatorInfo={validator} compact />
178+
{/* Verification Notice */}
179+
<div className='flex items-start gap-2 mt-2'>
180+
<Info size={16} className='text-caution-light mt-0.5 flex-shrink-0' />
181+
<div className='flex flex-col gap-1'>
182+
<Text detail color='caution.light'>
183+
Please verify that the identity key above is the one you expect, rather than
184+
relying on the validator name (as that can be spoofed).
185+
</Text>
186+
</div>
187+
</div>
188+
</Card.Section>
189+
190+
{/* Amount Input */}
191+
<Card.Section>
192+
<AssetValueInput
193+
amount={amount}
194+
onAmountChange={stakingStore.setAmount}
195+
selectedAsset={selectedAsset}
196+
onAssetChange={handleAssetChange}
197+
balances={balancesToShow}
198+
assets={[]}
199+
amountPlaceholder={`Amount to ${actionLabel.toLowerCase()}...`}
200+
assetDialogTitle={
201+
action === 'delegate' ? 'Select UM Balance' : 'Select Delegation Tokens'
202+
}
203+
disabled={stakingStore.loading}
204+
/>
205+
</Card.Section>
206+
</div>
207+
208+
{/* Buttons */}
209+
<div className='flex gap-2'>
210+
<Button
211+
actionType={showCautionBanner ? 'accent' : 'default'}
212+
priority={showCautionBanner ? 'primary' : 'secondary'}
213+
onClick={handleClose}
214+
disabled={stakingStore.loading}
215+
>
216+
Choose another
217+
</Button>
218+
<Button
219+
actionType={showCautionBanner ? 'default' : 'accent'}
220+
priority={showCautionBanner ? 'secondary' : 'primary'}
221+
onClick={handleSubmit}
222+
disabled={!isFormValid || stakingStore.loading}
223+
>
224+
{getButtonText()}
225+
</Button>
226+
</div>
227+
</div>
228+
</Dialog.Content>
229+
</Dialog>
230+
);
231+
});

0 commit comments

Comments
 (0)