Skip to content

Commit

Permalink
Build UI for claiming unbonding tokens (#659)
Browse files Browse the repository at this point in the history
* Account for undelegate claims in txn classifications

* Add getters for getting an epoch index

* Create an SCT client

* Create a getter for a validator identity key from a metadata

* Add asIdentityKey getter

* Build out undelegateClaim actions

* Account for undelegate claims in the planner

* Install missing dep

* Fix merge conflict issue

* Put all claims into one transaction

* Remove unnecessary loader call

* Fix typo

* Account for more errors from tendermint

* Make the entire unbonding amount a tooltip

* Update deps to take advantage of TransactionPlanner RPC change

* Add validatorPenalty to the Staking querier

* Add a validatorPenalty method handler to our impl of Staking

* Create ActionDetails component

* Add getters for undelegate claims

* Display undelegate claims

* Fix layout issue

* Extract Separator component

* Tweak comment

* Fix bug with loading proving key

* Extract a helper

* Put staking slice in its own directory and extract a helper

* Fix Rust tests

* Run delegate script synchronously to avoid conflicts etc.

* Polyfill Array.fromAsync in our test environment

* Fix mock paths

* Revert "Polyfill Array.fromAsync in our test environment"

This reverts commit bb27e46.
  • Loading branch information
jessepinho authored Mar 12, 2024
1 parent 29d7b2a commit f06c2a8
Show file tree
Hide file tree
Showing 37 changed files with 641 additions and 106 deletions.
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 () => {
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

0 comments on commit f06c2a8

Please sign in to comment.