Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Build UI for claiming unbonding tokens #659

Merged
merged 31 commits into from
Mar 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
81bddf1
Account for undelegate claims in txn classifications
jessepinho Mar 4, 2024
979fa0d
Add getters for getting an epoch index
jessepinho Mar 5, 2024
7fa5ec8
Create an SCT client
jessepinho Mar 5, 2024
247ad26
Create a getter for a validator identity key from a metadata
jessepinho Mar 5, 2024
a2c7569
Add asIdentityKey getter
jessepinho Mar 5, 2024
930f30c
Build out undelegateClaim actions
jessepinho Mar 5, 2024
335a391
Account for undelegate claims in the planner
jessepinho Mar 5, 2024
42c73d6
Install missing dep
jessepinho Mar 5, 2024
a3f1f54
Fix merge conflict issue
jessepinho Mar 5, 2024
74ff78a
Put all claims into one transaction
jessepinho Mar 5, 2024
045d6f8
Remove unnecessary loader call
jessepinho Mar 5, 2024
d647e68
Fix typo
jessepinho Mar 5, 2024
7ffce35
Account for more errors from tendermint
jessepinho Mar 6, 2024
a5f1343
Make the entire unbonding amount a tooltip
jessepinho Mar 6, 2024
b23c832
Update deps to take advantage of TransactionPlanner RPC change
jessepinho Mar 7, 2024
444b630
Add validatorPenalty to the Staking querier
jessepinho Mar 7, 2024
45fd748
Add a validatorPenalty method handler to our impl of Staking
jessepinho Mar 7, 2024
a45c3bc
Create ActionDetails component
jessepinho Mar 8, 2024
d1b3ba3
Add getters for undelegate claims
jessepinho Mar 8, 2024
abe99e7
Display undelegate claims
jessepinho Mar 8, 2024
0e67178
Fix layout issue
jessepinho Mar 8, 2024
50027a5
Extract Separator component
jessepinho Mar 8, 2024
99fec08
Tweak comment
jessepinho Mar 8, 2024
c5c3211
Fix bug with loading proving key
jessepinho Mar 8, 2024
ca1b118
Extract a helper
jessepinho Mar 8, 2024
51965f5
Put staking slice in its own directory and extract a helper
jessepinho Mar 8, 2024
2173cf5
Fix Rust tests
jessepinho Mar 8, 2024
5568c61
Run delegate script synchronously to avoid conflicts etc.
jessepinho Mar 9, 2024
07ad652
Polyfill Array.fromAsync in our test environment
jessepinho Mar 9, 2024
035a065
Fix mock paths
jessepinho Mar 9, 2024
4552ea7
Revert "Polyfill Array.fromAsync in our test environment"
jessepinho Mar 9, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/minifront/src/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SimulationService } from '@buf/penumbra-zone_penumbra.connectrpc_es/pen
import { CustodyService } from '@buf/penumbra-zone_penumbra.connectrpc_es/penumbra/custody/v1/custody_connect';
import { QueryService as StakeService } from '@buf/penumbra-zone_penumbra.connectrpc_es/penumbra/core/component/stake/v1/stake_connect';
import { Query as IbcClientService } from '@buf/cosmos_ibc.connectrpc_es/ibc/core/client/v1/query_connect';
import { QueryService as SctService } from '@buf/penumbra-zone_penumbra.connectrpc_es/penumbra/core/component/sct/v1/sct_connect';

export const viewClient = createPraxClient(ViewService);

Expand All @@ -13,4 +14,6 @@ export const simulateClient = createPraxClient(SimulationService);

export const ibcClient = createPraxClient(IbcClientService);

export const sctClient = createPraxClient(SctService);

export const stakeClient = createPraxClient(StakeService);
60 changes: 37 additions & 23 deletions apps/minifront/src/components/staking/account/header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Tooltip,
TooltipTrigger,
TooltipContent,
Button,
} from '@penumbra-zone/ui';
import { AccountSwitcher } from '@penumbra-zone/ui/components/ui/account-switcher';
import { ValueViewComponent } from '@penumbra-zone/ui/components/ui/tx/view/value';
Expand Down Expand Up @@ -34,8 +35,13 @@ const zeroBalanceUm = new ValueView({
* various token types related to staking.
*/
export const Header = () => {
const { account, setAccount, unstakedTokensByAccount, unbondingTokensByAccount } =
useStore(stakingSelector);
const {
account,
setAccount,
unstakedTokensByAccount,
unbondingTokensByAccount,
undelegateClaim,
} = useStore(stakingSelector);
const unstakedTokens = unstakedTokensByAccount.get(account);
const unbondingTokens = unbondingTokensByAccount.get(account);
const accountSwitcherFilter = useStore(accountsSelector);
Expand All @@ -46,33 +52,41 @@ export const Header = () => {
<div className='flex flex-col gap-2'>
<AccountSwitcher account={account} onChange={setAccount} filter={accountSwitcherFilter} />

<div className='flex justify-center gap-8'>
<div className='flex items-start justify-center gap-8'>
<Stat label='Available to delegate'>
<ValueViewComponent view={unstakedTokens ?? zeroBalanceUm} />
</Stat>

<Stat label='Unbonding amount'>
<div className='flex gap-2'>
<ValueViewComponent view={unbondingTokens?.total ?? zeroBalanceUm} />
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<img src='./info-icon.svg' className='size-4' alt='An info icon' />
</TooltipTrigger>
<TooltipContent>
<div className='flex flex-col gap-4'>
<div className='max-w-[250px]'>
Total amount of UM you will receive when all your unbonding tokens are
claimed, assuming no slashing.
</div>
{unbondingTokens?.tokens.map(token => (
<ValueViewComponent key={getDisplayDenomFromView(token)} view={token} />
))}
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<ValueViewComponent view={unbondingTokens?.total ?? zeroBalanceUm} />
</TooltipTrigger>
<TooltipContent>
<div className='flex flex-col gap-4'>
<div className='max-w-[250px]'>
Total amount of UM you will receive when all your unbonding tokens are
claimed, assuming no slashing.
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{unbondingTokens?.tokens.length && (
<>
{unbondingTokens.tokens.map(token => (
<ValueViewComponent key={getDisplayDenomFromView(token)} view={token} />
))}

<Button
className='self-end px-4 text-white'
onClick={() => void undelegateClaim()}
>
Claim
</Button>
</>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Stat>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { ValueView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb';
import {
TransactionPlannerRequest_UndelegateClaim,
TransactionPlannerRequest,
} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb';
import {
getStartEpochIndexFromValueView,
getValidatorIdentityKeyAsBech32StringFromValueView,
asIdentityKey,
getAmount,
} from '@penumbra-zone/getters';
import { stakeClient, viewClient, sctClient } from '../../clients';

const getUndelegateClaimPlannerRequest =
(endEpochIndex: bigint) => async (unbondingToken: ValueView) => {
const startEpochIndex = getStartEpochIndexFromValueView(unbondingToken);
const validatorIdentityKeyAsBech32String =
getValidatorIdentityKeyAsBech32StringFromValueView(unbondingToken);
const identityKey = asIdentityKey(validatorIdentityKeyAsBech32String);

const { penalty } = await stakeClient.validatorPenalty({
startEpochIndex,
endEpochIndex,
identityKey,
});

return new TransactionPlannerRequest_UndelegateClaim({
validatorIdentity: identityKey,
startEpochIndex,
penalty,
unbondingAmount: getAmount(unbondingToken),
});
};

export const assembleUndelegateClaimRequest = async ({
account,
unbondingTokens,
}: {
account: number;
unbondingTokens: ValueView[];
}) => {
const { fullSyncHeight } = await viewClient.status({});
const { epoch } = await sctClient.epochByHeight({ height: fullSyncHeight });
const endEpochIndex = epoch?.index;
if (!endEpochIndex) return;

return new TransactionPlannerRequest({
undelegationClaims: await Promise.all(
unbondingTokens.map(getUndelegateClaimPlannerRequest(endEpochIndex)),
),
source: { account },
});
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { StoreApi, UseBoundStore, create } from 'zustand';
import { AllSlices, initializeStore } from '.';
import { AllSlices, initializeStore } from '..';
import {
ValidatorInfo,
ValidatorInfoResponse,
Expand All @@ -15,7 +15,7 @@ import {
AddressView,
IdentityKey,
} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb';
import { accountsSelector } from './staking';
import { accountsSelector } from '.';

const validator1IdentityKey = new IdentityKey({ ik: new Uint8Array([1, 2, 3]) });
const validator1Bech32IdentityKey = bech32IdentityKey(validator1IdentityKey);
Expand Down Expand Up @@ -80,7 +80,7 @@ const mockStakeClient = vi.hoisted(() => ({
}),
}));

vi.mock('../fetchers/balances', () => ({
vi.mock('../../fetchers/balances', () => ({
getBalances: vi.fn(async () =>
Promise.resolve([
{
Expand Down Expand Up @@ -168,7 +168,7 @@ const mockViewClient = vi.hoisted(() => ({
assetMetadataById: vi.fn(() => new Metadata()),
}));

vi.mock('../clients', () => ({
vi.mock('../../clients', () => ({
stakeClient: mockStakeClient,
viewClient: mockViewClient,
}));
Expand All @@ -194,6 +194,7 @@ describe('Staking Slice', () => {
loadUnstakedAndUnbondingTokensByAccount: expect.any(Function) as unknown,
delegate: expect.any(Function) as unknown,
undelegate: expect.any(Function) as unknown,
undelegateClaim: expect.any(Function) as unknown,
onClickActionButton: expect.any(Function) as unknown,
onClose: expect.any(Function) as unknown,
setAmount: expect.any(Function) as unknown,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ValidatorInfo } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/stake/v1/stake_pb';
import { AllSlices, SliceCreator } from '.';
import { getDelegationsForAccount } from '../fetchers/staking';
import { AllSlices, SliceCreator } from '..';
import { getDelegationsForAccount } from '../../fetchers/staking';
import {
getAmount,
getAssetIdFromValueView,
Expand All @@ -20,7 +20,7 @@ import {
splitLoHi,
} from '@penumbra-zone/types';
import { ValueView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb';
import { BalancesByAccount, getBalancesByAccount } from '../fetchers/balances/by-account';
import { BalancesByAccount, getBalancesByAccount } from '../../fetchers/balances/by-account';
import {
localAssets,
STAKING_TOKEN,
Expand All @@ -29,9 +29,10 @@ import {
} from '@penumbra-zone/constants';
import { AddressIndex } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/keys/v1/keys_pb';
import { TransactionToast } from '@penumbra-zone/ui';
import { authWitnessBuild, broadcast, getTxHash, plan, userDeniedTransaction } from './helpers';
import { authWitnessBuild, broadcast, getTxHash, plan, userDeniedTransaction } from '../helpers';
import { TransactionPlannerRequest } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb';
import { BigNumber } from 'bignumber.js';
import { assembleUndelegateClaimRequest } from './assemble-undelegate-claim-request';

const STAKING_TOKEN_DISPLAY_DENOM_EXPONENT = (() => {
const stakingAsset = localAssets.find(asset => asset.display === STAKING_TOKEN);
Expand Down Expand Up @@ -76,6 +77,10 @@ export interface StakingSlice {
* Build and submit the Undelegate transaction.
*/
undelegate: () => Promise<void>;
/**
* Build and submit Undelegate Claim transaction(s).
*/
undelegateClaim: () => Promise<void>;
loadUnstakedAndUnbondingTokensByAccount: () => Promise<void>;
loading: boolean;
error: unknown;
Expand Down Expand Up @@ -279,6 +284,50 @@ export const createStakingSlice = (): SliceCreator<StakingSlice> => (set, get) =
});
}
},
undelegateClaim: async () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These state methods that build and submit a transaction, as well as manage toasts, are quite repetitive. I want to DRY them up, but this PR is already pretty huge (sorry), so I'll do that in a future PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you create an issue for that 🙏 ?

const { account, unbondingTokensByAccount } = get().staking;
const unbondingTokens = unbondingTokensByAccount.get(account)?.tokens;
if (!unbondingTokens) return;
const toast = new TransactionToast('undelegateClaim');
toast.onStart();

try {
const req = await assembleUndelegateClaimRequest({ account, unbondingTokens });
if (!req) return;
const transactionPlan = await plan(req);

// Reset form _after_ assembling the transaction planner request, since it
// depends on the state.
set(state => {
state.staking.action = undefined;
state.staking.validatorInfo = undefined;
});

const transaction = await authWitnessBuild({ transactionPlan }, status =>
toast.onBuildStatus(status),
);
const txHash = await getTxHash(transaction);
toast.txHash(txHash);
const { detectionHeight } = await broadcast({ transaction, awaitDetection: true }, status =>
toast.onBroadcastStatus(status),
);
toast.onSuccess(detectionHeight);

// Reload unbonding tokens and unstaked tokens to reflect their updated
// balances.
void get().staking.loadUnstakedAndUnbondingTokensByAccount();
} catch (e) {
if (userDeniedTransaction(e)) {
toast.onDenied();
} else {
toast.onFailure(e);
}
} finally {
set(state => {
state.staking.amount = '';
});
}
},
loading: false,
error: undefined,
votingPowerByValidatorInfo: {},
Expand Down
1 change: 1 addition & 0 deletions packages/getters/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"test": "vitest run"
},
"dependencies": {
"@penumbra-zone/constants": "workspace:*",
"bech32": "^2.0.0"
}
}
3 changes: 3 additions & 0 deletions packages/getters/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ export * from './address-view';
export * from './funding-stream';
export * from './metadata';
export * from './rate-data';
export * from './string';
export * from './swap';
export * from './swap-record';
export * from './spendable-note-record';
export * from './trading-pair';
export * from './transaction';
export * from './unclaimed-swaps-response';
export * from './undelegate-claim';
export * from './undelegate-claim-body';
export * from './validator';
export * from './validator-info';
export * from './validator-info-response';
Expand Down
60 changes: 57 additions & 3 deletions packages/getters/src/metadata.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb';
import { describe, expect, test } from 'vitest';
import { getDisplayDenomExponent } from './metadata';
import { describe, expect, it } from 'vitest';
import {
getDisplayDenomExponent,
getStartEpochIndex,
getValidatorIdentityKeyAsBech32String,
} from './metadata';

describe('getDisplayDenomExponent()', () => {
test("gets the exponent from the denom unit whose `denom` is equal to the metadata's `display` property", () => {
it("gets the exponent from the denom unit whose `denom` is equal to the metadata's `display` property", () => {
const penumbraMetadata = new Metadata({
display: 'penumbra',
denomUnits: [
Expand All @@ -25,3 +29,53 @@ describe('getDisplayDenomExponent()', () => {
expect(getDisplayDenomExponent(penumbraMetadata)).toBe(6);
});
});

describe('getStartEpochIndex()', () => {
it("gets the epoch index, coerced to a `BigInt`, from an unbonding token's asset ID", () => {
const metadata = new Metadata({ display: 'uunbonding_epoch_123_penumbravalid1abc123' });

expect(getStartEpochIndex(metadata)).toBe(123n);
});

it("returns `undefined` for a non-unbonding token's metadata", () => {
const metadata = new Metadata({ display: 'penumbra' });

expect(getStartEpochIndex.optional()(metadata)).toBeUndefined();
});

it('returns `undefined` for undefined metadata', () => {
expect(getStartEpochIndex.optional()(undefined)).toBeUndefined();
});
});

describe('getValidatorIdentityKeyAsBech32String()', () => {
describe('when passed metadata of a delegation token', () => {
const metadata = new Metadata({ display: 'delegation_penumbravalid1abc123' });

it("returns the bech32 representation of the validator's identity key", () => {
expect(getValidatorIdentityKeyAsBech32String(metadata)).toBe('penumbravalid1abc123');
});
});

describe('when passed metadata of an unbonding token', () => {
const metadata = new Metadata({ display: 'uunbonding_epoch_123_penumbravalid1abc123' });

it("returns the bech32 representation of the validator's identity key", () => {
expect(getValidatorIdentityKeyAsBech32String(metadata)).toBe('penumbravalid1abc123');
});
});

describe('when passed a token unrelated to validators', () => {
const metadata = new Metadata({ display: 'penumbra' });

it('returns `undefined`', () => {
expect(getValidatorIdentityKeyAsBech32String.optional()(metadata)).toBeUndefined();
});
});

describe('when passed undefined', () => {
it('returns `undefined`', () => {
expect(getValidatorIdentityKeyAsBech32String.optional()(undefined)).toBeUndefined();
});
});
});
Loading
Loading