Skip to content

Commit

Permalink
fees: ux for multi-asset fees (#1268)
Browse files Browse the repository at this point in the history
* add index for spendable notes table in idb

* scaffolding planner support for multi-asset fees

* filter for alt fee asset id

* fix fee rendering

* full multi-asset fee support ~

* attempt to pass CI

* partially address comments

* valentine + jesse feedback

* fix broken lockfile, update deps, update db version

* remove dangling minifront_url

* co-locate idb version with storage package

* remove idb version from extension .env

* support actions for alt fees

* linting and organization cleanup

* update lockfile

* remove extension dir trace

* fix lockfile?

* address valentine comments

* fix test suite

* try fixing rust tests

* rust lint

* rust lint

* add TODOs for #1310

* fix lint

---------

Co-authored-by: valentine <valentyn1789@gmail.com>
  • Loading branch information
TalDerei and Valentine1898 authored Jun 17, 2024
1 parent 1ee18e0 commit 29fc9f7
Show file tree
Hide file tree
Showing 24 changed files with 750 additions and 540 deletions.
29 changes: 23 additions & 6 deletions apps/minifront/src/components/send/send-form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useStore } from '../../../state';
import { sendSelector, sendValidationErrors } from '../../../state/send';
import { InputBlock } from '../../shared/input-block';
import { LoaderFunction, useLoaderData } from 'react-router-dom';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { getTransferableBalancesResponses, penumbraAddrValidation } from '../helpers';
import { abortLoader } from '../../../abort-loader';
import InputToken from '../../shared/input-token';
Expand All @@ -13,10 +13,11 @@ import { GasFee } from '../../shared/gas-fee';
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 { getStakingTokenMetadata } from '../../../fetchers/registry';
import { hasStakingToken } from '../../../fetchers/staking-token';

export interface SendLoaderResponse {
assetBalances: BalancesResponse[];
feeAssetMetadata: Metadata;
stakingAssetMetadata: Metadata;
}

export const SendAssetBalanceLoader: LoaderFunction = async (): Promise<SendLoaderResponse> => {
Expand All @@ -29,13 +30,13 @@ export const SendAssetBalanceLoader: LoaderFunction = async (): Promise<SendLoad
state.send.selection = assetBalances[0];
});
}
const feeAssetMetadata = await getStakingTokenMetadata();
const stakingAssetMetadata = await getStakingTokenMetadata();

return { assetBalances, feeAssetMetadata };
return { assetBalances, stakingAssetMetadata };
};

export const SendForm = () => {
const { assetBalances, feeAssetMetadata } = useLoaderData() as SendLoaderResponse;
const { assetBalances, stakingAssetMetadata } = useLoaderData() as SendLoaderResponse;
const {
selection,
amount,
Expand All @@ -51,6 +52,11 @@ export const SendForm = () => {
sendTx,
txInProgress,
} = useStore(sendSelector);
// State to manage privacy warning display
const [showNonNativeFeeWarning, setshowNonNativeFeeWarning] = useState(false);

// Check if the user has native staking tokens
const stakingToken = hasStakingToken(assetBalances, stakingAssetMetadata);

useRefreshFee();

Expand Down Expand Up @@ -90,6 +96,8 @@ export const SendForm = () => {
onInputChange={amount => {
if (Number(amount) < 0) return;
setAmount(amount);
// Conditionally prompt a privacy warning about non-native fee tokens
setshowNonNativeFeeWarning(Number(amount) > 0 && !stakingToken);
}}
validations={[
{
Expand All @@ -100,11 +108,20 @@ export const SendForm = () => {
]}
balances={assetBalances}
/>
{showNonNativeFeeWarning && (
<div className='rounded border border-yellow-500 bg-gray-800 p-4 text-yellow-500'>
<strong>Privacy Warning:</strong>
<span className='block'>
Using non-native tokens for transaction fees may pose a privacy risk. It is recommended
to use the native token (UM) for better privacy and security.
</span>
</div>
)}

<GasFee
fee={fee}
feeTier={feeTier}
feeAssetMetadata={feeAssetMetadata}
stakingAssetMetadata={stakingAssetMetadata}
setFeeTier={setFeeTier}
/>

Expand Down
6 changes: 3 additions & 3 deletions apps/minifront/src/components/shared/gas-fee.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,20 @@ const FEE_TIER_OPTIONS: SegmentedPickerOption<FeeTier_Tier>[] = [
export const GasFee = ({
fee,
feeTier,
feeAssetMetadata,
stakingAssetMetadata,
setFeeTier,
}: {
fee: Fee | undefined;
feeTier: FeeTier_Tier;
feeAssetMetadata: Metadata;
stakingAssetMetadata: Metadata;
setFeeTier: (feeTier: FeeTier_Tier) => void;
}) => {
let feeValueView: ValueView | undefined;
if (fee?.amount)
feeValueView = new ValueView({
valueView: {
case: 'knownAssetId',
value: { amount: fee.amount, metadata: feeAssetMetadata },
value: { amount: fee.amount, metadata: stakingAssetMetadata },
},
});

Expand Down
24 changes: 22 additions & 2 deletions apps/minifront/src/components/swap/swap-form/token-swap-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
getMetadataFromBalancesResponse,
} from '@penumbra-zone/getters/balances-response';
import { ArrowRight } from 'lucide-react';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { getBlockDate } from '../../../fetchers/block-date';
import { AllSlices } from '../../../state';
import { amountMoreThanBalance } from '../../../state/send';
Expand All @@ -25,6 +25,7 @@ import { AssetSelector } from '../../shared/asset-selector';
import BalanceSelector from '../../shared/balance-selector';
import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1/num_pb';
import { useStatus } from '../../../state/status';
import { hasStakingToken } from '../../../fetchers/staking-token';

const isValidAmount = (amount: string, assetIn?: BalancesResponse) =>
Number(amount) >= 0 && (!assetIn || !amountMoreThanBalance(assetIn, amount));
Expand Down Expand Up @@ -63,6 +64,7 @@ const tokenSwapInputSelector = (state: AllSlices) => ({
balancesResponses: state.swap.balancesResponses,
priceHistory: state.swap.priceHistory,
assetOutBalance: assetOutBalanceSelector(state),
hasStakingTokenMeta: state.swap.stakingAssetMetadata,
});

/**
Expand All @@ -85,7 +87,13 @@ export const TokenSwapInput = () => {
balancesResponses,
priceHistory,
assetOutBalance,
hasStakingTokenMeta,
} = useStoreShallow(tokenSwapInputSelector);
// State to manage privacy warning display
const [showNonNativeFeeWarning, setshowNonNativeFeeWarning] = useState(false);

// Check if the user has native staking tokens
const stakingToken = hasStakingToken(balancesResponses, hasStakingTokenMeta);

useEffect(() => {
if (!assetIn || !assetOut) return;
Expand Down Expand Up @@ -123,9 +131,9 @@ export const TokenSwapInput = () => {
onChange={e => {
if (!isValidAmount(e.target.value, assetIn)) return;
setAmount(e.target.value);
setshowNonNativeFeeWarning(Number(e.target.value) > 0 && !stakingToken);
}}
/>

<div className='flex gap-4 sm:contents'>
{assetIn && (
<div className='ml-auto hidden h-full flex-col justify-end self-end sm:flex'>
Expand Down Expand Up @@ -162,6 +170,18 @@ export const TokenSwapInput = () => {
/>
) : null}
</div>
{showNonNativeFeeWarning && (
<>
<div className='h-4'></div> {/* This div adds an empty line */}
<div className='rounded border border-yellow-500 bg-gray-800 p-4 text-yellow-500'>
<strong>Privacy Warning:</strong>
<span className='block'>
Using non-native tokens for transaction fees may pose a privacy risk. It is
recommended to use the native token (UM) for better privacy and security.
</span>
</div>
</>
)}
</Box>
);
};
3 changes: 3 additions & 0 deletions apps/minifront/src/components/swap/swap-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SwapRecord } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/vie
import { Metadata } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb';
import { getSwappableBalancesResponses, isSwappable } from './helpers';
import { getAllAssets } from '../../fetchers/assets';
import { getStakingTokenMetadata } from '../../fetchers/registry';

export interface UnclaimedSwapsWithMetadata {
swap: SwapRecord;
Expand All @@ -16,11 +17,13 @@ export type SwapLoaderResponse = UnclaimedSwapsWithMetadata[];

const getAndSetDefaultAssetBalances = async (swappableAssets: Metadata[]) => {
const balancesResponses = await getSwappableBalancesResponses();
const stakingTokenAssetMetadata = await getStakingTokenMetadata();

// set initial denom in if there is an available balance
if (balancesResponses[0]) {
useStore.getState().swap.setAssetIn(balancesResponses[0]);
useStore.getState().swap.setAssetOut(swappableAssets[0]!);
useStore.getState().swap.setStakingAssetMetadata(stakingTokenAssetMetadata);
}

return balancesResponses;
Expand Down
13 changes: 13 additions & 0 deletions apps/minifront/src/fetchers/staking-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
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 { getAssetIdFromValueView } from '@penumbra-zone/getters/value-view';
import { getAssetId } from '@penumbra-zone/getters/metadata';

export const hasStakingToken = (
assetBalances: BalancesResponse[],
stakingAssetMetadata: Metadata,
): boolean => {
return assetBalances.some(asset =>
getAssetIdFromValueView(asset.balanceView).equals(getAssetId(stakingAssetMetadata)),
);
};
8 changes: 8 additions & 0 deletions apps/minifront/src/state/swap/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface SimulateSwapResult {

interface Actions {
setBalancesResponses: (balancesResponses: BalancesResponse[]) => void;
setStakingAssetMetadata: (metadata: Metadata) => void;
setSwappableAssets: (assets: Metadata[]) => void;
setAssetIn: (asset: BalancesResponse) => void;
setAmount: (amount: string) => void;
Expand All @@ -39,6 +40,7 @@ interface Actions {

interface State {
balancesResponses: BalancesResponse[];
stakingAssetMetadata: Metadata;
swappableAssets: Metadata[];
assetIn?: BalancesResponse;
amount: string;
Expand All @@ -59,6 +61,7 @@ const INITIAL_STATE: State = {
balancesResponses: [],
duration: 'instant',
txInProgress: false,
stakingAssetMetadata: new Metadata(),
};

export type SwapSlice = Actions & State & Subslices;
Expand Down Expand Up @@ -92,6 +95,11 @@ export const createSwapSlice = (): SliceCreator<SwapSlice> => (set, get, store)
state.swap.balancesResponses = balancesResponses;
});
},
setStakingAssetMetadata: stakingAssetMetadata => {
set(state => {
state.swap.stakingAssetMetadata = stakingAssetMetadata;
});
},
setSwappableAssets: swappableAssets => {
set(state => {
state.swap.swappableAssets = swappableAssets;
Expand Down
4 changes: 4 additions & 0 deletions packages/query/src/block-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,12 @@ export class BlockProcessor implements BlockProcessorInterface {
await this.indexedDb.saveFmdParams(compactBlock.fmdParameters);
}
if (compactBlock.gasPrices) {
// TODO #1310 pre-populate assetId for native GasPrices using stakingTokenAssetId
await this.indexedDb.saveGasPrices(compactBlock.gasPrices);
}
// if (compactBlock.altGasPrices) {
// TODO #1310 save altGasPrices to indexed-db
// }

// wasm view server scan
// - decrypts new notes
Expand Down
2 changes: 2 additions & 0 deletions packages/services/src/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export interface IndexedDbMock {
getPricesForAsset?: Mock;
getAuction?: Mock;
getAuctionOutstandingReserves?: Mock;
hasStakingAssetBalance?: Mock;
fetchStakingTokenId?: Mock;
}

export interface AuctionMock {
Expand Down
49 changes: 49 additions & 0 deletions packages/services/src/view-service/fees.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { AssetId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb';
import {
TransactionPlannerRequest,
TransactionPlannerRequest_ActionDutchAuctionEnd,
TransactionPlannerRequest_ActionDutchAuctionSchedule,
TransactionPlannerRequest_ActionDutchAuctionWithdraw,
TransactionPlannerRequest_Output,
TransactionPlannerRequest_Swap,
} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb';
import { Code, ConnectError } from '@connectrpc/connect';

export const extractAltFee = (request: TransactionPlannerRequest): AssetId | undefined => {
// Note: expand the possible types as we expand our support to more actions in the future.
const fields = [
{ name: 'outputs', value: request.outputs },
{ name: 'swaps', value: request.swaps },
{ name: 'dutchAuctionScheduleActions', value: request.dutchAuctionScheduleActions },
{ name: 'dutchAuctionEndActions', value: request.dutchAuctionEndActions },
{ name: 'dutchAuctionWithdrawActions', value: request.dutchAuctionWithdrawActions },
];

const nonEmptyField = fields.find(field => field.value.length > 0);

if (!nonEmptyField) {
throw new ConnectError('No non-empty field found in the request.', Code.InvalidArgument);
}

const action = nonEmptyField.value[0]!;

switch (nonEmptyField.name) {
case 'outputs':
return (action as TransactionPlannerRequest_Output).value?.assetId;
case 'swaps':
return (action as TransactionPlannerRequest_Swap).value?.assetId;
case 'dutchAuctionScheduleActions':
return (action as TransactionPlannerRequest_ActionDutchAuctionSchedule).description?.outputId;
case 'dutchAuctionEndActions':
return new AssetId({
inner: (action as TransactionPlannerRequest_ActionDutchAuctionEnd).auctionId?.inner,
});
case 'dutchAuctionWithdrawActions':
return new AssetId({
inner: (action as TransactionPlannerRequest_ActionDutchAuctionWithdraw).auctionId?.inner,
});
default:
console.warn('Unsupported action type.');
throw new ConnectError('Unsupported action type.', Code.InvalidArgument);
}
};
1 change: 1 addition & 0 deletions packages/services/src/view-service/gas-prices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,6 @@ export const gasPrices: Impl['gasPrices'] = async (_, ctx) => {

return {
gasPrices,
// TODO #1310 add altGasPrices
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ describe('TransactionPlanner request handler', () => {
getAppParams: vi.fn(),
getGasPrices: vi.fn(),
constants: vi.fn(),
fetchStakingTokenId: vi.fn(),
hasStakingAssetBalance: vi.fn(),
};

mockServices = {
Expand Down Expand Up @@ -77,47 +79,11 @@ describe('TransactionPlanner request handler', () => {
compactBlockSpacePrice: 120n,
}),
);

mockIndexedDb.fetchStakingTokenId?.mockResolvedValueOnce(true);
mockIndexedDb.hasStakingAssetBalance?.mockResolvedValueOnce(true);
await transactionPlanner(req, mockCtx);

expect(mockPlanTransaction.mock.calls.length === 1).toBeTruthy();
});

test('should throw error if FmdParameters not available', async () => {
await expect(transactionPlanner(req, mockCtx)).rejects.toThrow('FmdParameters not available');
});

test('should throw error if SctParameters not available', async () => {
mockIndexedDb.getFmdParams?.mockResolvedValueOnce(new FmdParameters());
mockIndexedDb.getAppParams?.mockResolvedValueOnce(
new AppParameters({
chainId: 'penumbra-testnet-mock',
}),
);
await expect(transactionPlanner(req, mockCtx)).rejects.toThrow('SctParameters not available');
});

test('should throw error if ChainId not available', async () => {
mockIndexedDb.getFmdParams?.mockResolvedValueOnce(new FmdParameters());
mockIndexedDb.getAppParams?.mockResolvedValueOnce(
new AppParameters({
sctParams: new SctParameters({
epochDuration: 719n,
}),
}),
);
await expect(transactionPlanner(req, mockCtx)).rejects.toThrow('ChainId not available');
});

test('should throw error if Gas prices is not available', async () => {
mockIndexedDb.getFmdParams?.mockResolvedValueOnce(new FmdParameters());
mockIndexedDb.getAppParams?.mockResolvedValueOnce(
new AppParameters({
chainId: 'penumbra-testnet-mock',
sctParams: new SctParameters({
epochDuration: 719n,
}),
}),
);
await expect(transactionPlanner(req, mockCtx)).rejects.toThrow('Gas prices is not available');
});
});
Loading

0 comments on commit 29fc9f7

Please sign in to comment.