From d0cc2ee9bb5cb0e77238238d2546751fc70286a1 Mon Sep 17 00:00:00 2001 From: Max Korsunov Date: Fri, 21 Feb 2025 10:18:40 +0400 Subject: [PATCH] feat(ui): #2053: implement `SwapClaim` action view (#2061) * feat(ui): #2053: implement `SwapClaim` action view * chore: changeset * fix(ui): #2053: fix SwapClaim action view * fix: format --- .changeset/famous-meals-check.md | 6 + packages/getters/src/swap-claim-view.ts | 5 + packages/ui/src/ActionView/action-view.tsx | 22 ++- .../ui/src/ActionView/actions/swap-claim.tsx | 137 +++++++++++++++++- packages/ui/src/ActionView/actions/swap.tsx | 103 +++++++------ packages/ui/src/ActionView/index.stories.tsx | 4 + packages/ui/src/ActionView/types.ts | 3 + packages/ui/src/utils/bufs/action-view.ts | 31 +++- packages/ui/src/utils/bufs/index.ts | 1 + packages/ui/src/utils/bufs/registry.ts | 17 +++ 10 files changed, 279 insertions(+), 50 deletions(-) create mode 100644 .changeset/famous-meals-check.md create mode 100644 packages/ui/src/utils/bufs/registry.ts diff --git a/.changeset/famous-meals-check.md b/.changeset/famous-meals-check.md new file mode 100644 index 0000000000..682c1d8dea --- /dev/null +++ b/.changeset/famous-meals-check.md @@ -0,0 +1,6 @@ +--- +'@penumbra-zone/getters': minor +'@penumbra-zone/ui': minor +--- + +Implement `SwapClaim` action view diff --git a/packages/getters/src/swap-claim-view.ts b/packages/getters/src/swap-claim-view.ts index 45f02ca3d0..83bd8960ba 100644 --- a/packages/getters/src/swap-claim-view.ts +++ b/packages/getters/src/swap-claim-view.ts @@ -2,6 +2,11 @@ import { SwapClaimView } from '@penumbra-zone/protobuf/penumbra/core/component/d import { createGetter } from './utils/create-getter.js'; import { getValue } from './note-view.js'; +export const getOutputData = createGetter( + (swapClaimView?: SwapClaimView) => + swapClaimView?.swapClaimView.value?.swapClaim?.body?.outputData, +); + export const getOutput1 = createGetter((swapClaimView?: SwapClaimView) => swapClaimView?.swapClaimView.case === 'visible' ? swapClaimView.swapClaimView.value.output1 diff --git a/packages/ui/src/ActionView/action-view.tsx b/packages/ui/src/ActionView/action-view.tsx index 804c4a8884..0a354c65cd 100644 --- a/packages/ui/src/ActionView/action-view.tsx +++ b/packages/ui/src/ActionView/action-view.tsx @@ -1,6 +1,6 @@ import { FC } from 'react'; import { ActionView as ActionViewMessage } from '@penumbra-zone/protobuf/penumbra/core/transaction/v1/transaction_pb'; -import { ActionViewType, ActionViewValueType } from './types'; +import { ActionViewType, ActionViewValueType, GetMetadataByAssetId } from './types'; import { UnknownAction } from './actions/unknown'; import { SpendAction } from './actions/spend'; @@ -31,15 +31,24 @@ import { CommunityPoolSpendAction } from './actions/community-pool-spend'; import { LiquidityTournamentVoteAction } from './actions/liquidity-tournament-vote'; export interface ActionViewProps { + /** + * The `ActionViewMessage` protobuf describing an action within a transaction in Penumbra. + * Can be one of multiple types: Spend, Output, Swap, SwapClaim, etc. + */ action: ActionViewMessage; + /** + * A helper function that is needed for better fees calculation. + * Can be omitted, but it generally improves the rendering logic, especially for opaque views. + */ + getMetadataByAssetId?: GetMetadataByAssetId; } const componentMap = { spend: SpendAction, output: OutputAction, swap: SwapAction, - // TODO: Implement the actions below swapClaim: SwapClaimAction, + // TODO: Implement the actions below delegate: DelegateAction, delegatorVote: DelegatorVoteAction, undelegate: UndelegateAction, @@ -69,9 +78,12 @@ const componentMap = { * In Penumbra, each transaction has 'actions' of different types, * representing a blockchain state change performed by a transaction. */ -export const ActionView = ({ action }: ActionViewProps) => { +export const ActionView = ({ action, getMetadataByAssetId }: ActionViewProps) => { const type = action.actionView.case ?? 'unknown'; - const Component = componentMap[type] as FC<{ value?: ActionViewValueType }>; + const Component = componentMap[type] as FC<{ + value?: ActionViewValueType; + getMetadataByAssetId?: GetMetadataByAssetId; + }>; - return ; + return ; }; diff --git a/packages/ui/src/ActionView/actions/swap-claim.tsx b/packages/ui/src/ActionView/actions/swap-claim.tsx index a3a01d7b42..498f0d9d53 100644 --- a/packages/ui/src/ActionView/actions/swap-claim.tsx +++ b/packages/ui/src/ActionView/actions/swap-claim.tsx @@ -1,10 +1,141 @@ +import { useMemo } from 'react'; +import { ArrowRight } from 'lucide-react'; +import { isZero } from '@penumbra-zone/types/amount'; +import { shorten } from '@penumbra-zone/types/string'; +import { uint8ArrayToHex } from '@penumbra-zone/types/hex'; +import { getAmount, getMetadata } from '@penumbra-zone/getters/value-view'; import { SwapClaimView } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; -import { UnknownAction } from './unknown'; +import { + getOutput1Value, + getOutput2Value, + getSwapClaimFee, + getOutputData, +} from '@penumbra-zone/getters/swap-claim-view'; +import { Density } from '../../Density'; +import { ValueViewComponent } from '../../ValueView'; +import { useDensity } from '../../utils/density'; +import { ActionRow } from './action-row'; +import { ActionWrapper } from './wrapper'; +import { parseSwapFees } from './swap'; +import { GetMetadataByAssetId } from '../types'; +import { ValueView } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; export interface SwapClaimActionProps { value: SwapClaimView; + /** A helper needed to calculate the SwapClaim fees */ + getMetadataByAssetId?: GetMetadataByAssetId; } -export const SwapClaimAction = ({ value }: SwapClaimActionProps) => { - return ; +/** + * Based on the visibility of the SwapClaim view, retrieves its values from the `value` + * property for 'visible', and from `outputData` for 'opaque'. + */ +const useSwapClaimValues = ({ value, getMetadataByAssetId }: SwapClaimActionProps) => { + if (value.swapClaimView.case === 'visible') { + const value1 = getOutput1Value.optional(value); + const value2 = getOutput2Value.optional(value); + + return { + value1, + value2, + }; + } + + const outputData = getOutputData.optional(value); + const value1 = + outputData?.lambda1 && + new ValueView({ + valueView: + outputData.tradingPair?.asset1 && getMetadataByAssetId + ? { + case: 'knownAssetId', + value: { + amount: outputData.lambda1, + metadata: getMetadataByAssetId(outputData.tradingPair.asset1), + }, + } + : { + case: 'unknownAssetId', + value: { + amount: outputData.lambda1, + assetId: outputData.tradingPair?.asset1, + }, + }, + }); + + const value2 = + outputData?.lambda2 && + new ValueView({ + valueView: + outputData.tradingPair?.asset2 && getMetadataByAssetId + ? { + case: 'knownAssetId', + value: { + amount: outputData.lambda2, + metadata: getMetadataByAssetId(outputData.tradingPair.asset2), + }, + } + : { + case: 'unknownAssetId', + value: { + amount: outputData.lambda2, + assetId: outputData.tradingPair?.asset2, + }, + }, + }); + + return { + value1, + value2, + }; +}; + +export const SwapClaimAction = ({ value, getMetadataByAssetId }: SwapClaimActionProps) => { + const density = useDensity(); + + const { value1, value2 } = useSwapClaimValues({ value, getMetadataByAssetId }); + + const amount1 = getAmount.optional(value1); + const amount2 = getAmount.optional(value2); + + const txId = useMemo(() => { + if (value.swapClaimView.case === 'opaque' || !value.swapClaimView.value?.swapTx) { + return undefined; + } + return uint8ArrayToHex(value.swapClaimView.value.swapTx.inner); + }, [value]); + + const fee = useMemo(() => { + const claimFee = getSwapClaimFee.optional(value); + const asset1 = getMetadata.optional(value1); + const asset2 = getMetadata.optional(value2); + return parseSwapFees(claimFee, asset1, asset2, getMetadataByAssetId); + }, [getMetadataByAssetId, value1, value2, value]); + + return ( + + {!!fee && } + {!!txId && } + + } + > + + + + + + + ); }; diff --git a/packages/ui/src/ActionView/actions/swap.tsx b/packages/ui/src/ActionView/actions/swap.tsx index 3a78db3160..621a523497 100644 --- a/packages/ui/src/ActionView/actions/swap.tsx +++ b/packages/ui/src/ActionView/actions/swap.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { ArrowRight } from 'lucide-react'; import { SwapView } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; import { Metadata, ValueView } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { Fee } from '@penumbra-zone/protobuf/penumbra/core/component/fee/v1/fee_pb'; import { getAsset1Metadata, getAsset2Metadata, @@ -14,6 +15,7 @@ import { isZero } from '@penumbra-zone/types/amount'; import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view'; import { uint8ArrayToHex } from '@penumbra-zone/types/hex'; import { shorten } from '@penumbra-zone/types/string'; +import { GetMetadataByAssetId } from '../types'; import { ValueViewComponent } from '../../ValueView'; import { useDensity } from '../../utils/density'; import { Density } from '../../Density'; @@ -22,6 +24,8 @@ import { ActionRow } from './action-row'; export interface SwapActionProps { value: SwapView; + /** A helper needed to calculate the Swap fees */ + getMetadataByAssetId: GetMetadataByAssetId; } const renderAmount = (value?: ValueView) => { @@ -32,7 +36,61 @@ const renderAmount = (value?: ValueView) => { return symbol ? `${getFormattedAmtFromValueView(value)} ${symbol}` : undefined; }; -export const SwapAction = ({ value }: SwapActionProps) => { +/** + * For Swap and SwapClaim actions, fees contain only the assetId and amount. This function + * calculates a Metadata from this assetId. It firstly tries to get the info from the action itself, + * and if it fails, it takes the Metadata from the registry (or ViewService, if passed). + */ +export const parseSwapFees = ( + fee?: Fee, + asset1?: Metadata, + asset2?: Metadata, + getMetadataByAssetId?: SwapActionProps['getMetadataByAssetId'], +): string | undefined => { + if (!fee) { + return undefined; + } + + let metadata: Metadata | undefined = undefined; + if (fee.assetId?.equals(asset1?.penumbraAssetId)) { + metadata = asset1; + } + if (fee.assetId?.equals(asset2?.penumbraAssetId)) { + metadata = asset1; + } + + if (!metadata && fee.assetId && getMetadataByAssetId) { + metadata = getMetadataByAssetId(fee.assetId); + } + + if (metadata) { + return renderAmount( + new ValueView({ + valueView: { + case: 'knownAssetId', + value: { + metadata, + amount: fee.amount, + }, + }, + }), + ); + } + + return renderAmount( + new ValueView({ + valueView: { + case: 'unknownAssetId', + value: { + assetId: fee.assetId, + amount: fee.amount, + }, + }, + }), + ); +}; + +export const SwapAction = ({ value, getMetadataByAssetId }: SwapActionProps) => { const density = useDensity(); const isOneWay = isOneWaySwap(value); @@ -52,50 +110,13 @@ export const SwapAction = ({ value }: SwapActionProps) => { return uint8ArrayToHex(claim.inner); }, [value]); - // This function calculates metadata based on fee's AssetId from input or output metadata. - // TODO: implement fees paid from non-input/output assets (e.g. connect with registry) const fee = useMemo(() => { const claimFee = getClaimFeeFromSwapView.optional(value); - if (!claimFee) { - return undefined; - } - - let metadata: Metadata | undefined = undefined; const asset1 = getAsset1Metadata.optional(value); const asset2 = getAsset2Metadata.optional(value); - if (claimFee.assetId?.equals(asset1?.penumbraAssetId)) { - metadata = asset1; - } - if (claimFee.assetId?.equals(asset2?.penumbraAssetId)) { - metadata = asset1; - } - - if (metadata) { - return renderAmount( - new ValueView({ - valueView: { - case: 'knownAssetId', - value: { - metadata, - amount: claimFee.amount, - }, - }, - }), - ); - } - return renderAmount( - new ValueView({ - valueView: { - case: 'unknownAssetId', - value: { - assetId: claimFee.assetId, - amount: claimFee.amount, - }, - }, - }), - ); - }, [value]); + return parseSwapFees(claimFee, asset1, asset2, getMetadataByAssetId); + }, [getMetadataByAssetId, value]); if (!isOneWay) { return ( @@ -110,7 +131,7 @@ export const SwapAction = ({ value }: SwapActionProps) => { infoRows={ isVisible && ( <> - {!!fee && } + {!!fee && } {!!txId && ( )} diff --git a/packages/ui/src/ActionView/index.stories.tsx b/packages/ui/src/ActionView/index.stories.tsx index 7f1d60e29f..16b503a41e 100644 --- a/packages/ui/src/ActionView/index.stories.tsx +++ b/packages/ui/src/ActionView/index.stories.tsx @@ -10,6 +10,8 @@ import { OutputActionOpaque, SwapActionOpaque, SwapClaimAction, + SwapClaimActionOpaque, + registry, } from '../utils/bufs'; const OPTIONS: Record = { @@ -20,6 +22,7 @@ const OPTIONS: Record = { 'Opaque: Spend': SpendActionOpaque, 'Opaque: Output': OutputActionOpaque, 'Opaque: Swap': SwapActionOpaque, + 'Opaque: SwapClaim': SwapClaimActionOpaque, }; const meta: Meta = { @@ -39,5 +42,6 @@ type Story = StoryObj; export const Basic: Story = { args: { action: SpendAction, + getMetadataByAssetId: registry.tryGetMetadata, }, }; diff --git a/packages/ui/src/ActionView/types.ts b/packages/ui/src/ActionView/types.ts index 066bdabd2a..b29dd6e3b0 100644 --- a/packages/ui/src/ActionView/types.ts +++ b/packages/ui/src/ActionView/types.ts @@ -1,5 +1,8 @@ import { ActionView } from '@penumbra-zone/protobuf/penumbra/core/transaction/v1/transaction_pb'; +import { AssetId, Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; export type ActionViewType = Exclude; export type ActionViewValueType = Exclude; + +export type GetMetadataByAssetId = (assetId: AssetId) => Metadata | undefined; diff --git a/packages/ui/src/utils/bufs/action-view.ts b/packages/ui/src/utils/bufs/action-view.ts index 76ab923db3..68eaeabdb0 100644 --- a/packages/ui/src/utils/bufs/action-view.ts +++ b/packages/ui/src/utils/bufs/action-view.ts @@ -143,7 +143,7 @@ export const SwapClaimAction = new ActionView({ }), output2: new NoteView({ address: ADDRESS_VIEW_DECODED, - value: PENUMBRA_VALUE_VIEW_ZERO, + value: PENUMBRA_VALUE_VIEW, }), swapTx: new TransactionId({ inner: new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]), @@ -161,3 +161,32 @@ export const SwapClaimAction = new ActionView({ }), }, }); + +export const SwapClaimActionOpaque = new ActionView({ + actionView: { + case: 'swapClaim', + value: new SwapClaimView({ + swapClaimView: { + case: 'opaque', + value: { + swapClaim: { + body: { + fee: new Fee({ + amount: AMOUNT_999, + assetId: PENUMBRA_METADATA.penumbraAssetId, + }), + outputData: { + tradingPair: { + asset1: USDC_METADATA.penumbraAssetId, + asset2: PENUMBRA_METADATA.penumbraAssetId, + }, + lambda1: AMOUNT_123_456_789, + lambda2: AMOUNT_999, + }, + }, + }, + }, + }, + }), + }, +}); diff --git a/packages/ui/src/utils/bufs/index.ts b/packages/ui/src/utils/bufs/index.ts index c57c01e9b7..35e833c12d 100644 --- a/packages/ui/src/utils/bufs/index.ts +++ b/packages/ui/src/utils/bufs/index.ts @@ -3,6 +3,7 @@ * environments only, and should not be used in the resulting library code. */ +export * from './registry'; export * from './metadata'; export * from './value-view'; export * from './address-view'; diff --git a/packages/ui/src/utils/bufs/registry.ts b/packages/ui/src/utils/bufs/registry.ts new file mode 100644 index 0000000000..9a30efeef4 --- /dev/null +++ b/packages/ui/src/utils/bufs/registry.ts @@ -0,0 +1,17 @@ +import { AssetId, Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { USDC_METADATA, PENUMBRA_METADATA, OSMO_METADATA } from './metadata'; +import { uint8ArrayToBase64 } from '@penumbra-zone/types/base64'; + +const METADATA_MAP: Record = { + /* eslint-disable @typescript-eslint/no-non-null-assertion -- it's only for storybook purposes */ + [uint8ArrayToBase64(USDC_METADATA.penumbraAssetId!.inner)]: USDC_METADATA, + [uint8ArrayToBase64(PENUMBRA_METADATA.penumbraAssetId!.inner)]: PENUMBRA_METADATA, + [uint8ArrayToBase64(OSMO_METADATA.penumbraAssetId!.inner)]: OSMO_METADATA, + /* eslint-enable @typescript-eslint/no-non-null-assertion -- enable again */ +}; + +export const registry = { + tryGetMetadata: (assetId: AssetId): Metadata | undefined => { + return METADATA_MAP[uint8ArrayToBase64(assetId.inner)]; + }, +};