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

fees: ux for multi-asset fees #1268

Merged
merged 29 commits into from
Jun 17, 2024
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
f0b146f
add index for spendable notes table in idb
TalDerei Jun 9, 2024
1f44143
scaffolding planner support for multi-asset fees
TalDerei Jun 9, 2024
c92ddfa
filter for alt fee asset id
TalDerei Jun 10, 2024
837d72b
fix fee rendering
TalDerei Jun 10, 2024
e8e0538
full multi-asset fee support ~
TalDerei Jun 10, 2024
6f0244e
attempt to pass CI
TalDerei Jun 10, 2024
45bfe51
Merge branch 'main' into ux-multi-fees
TalDerei Jun 10, 2024
c1b2aee
partially address comments
TalDerei Jun 12, 2024
23b6e19
valentine + jesse feedback
TalDerei Jun 13, 2024
fbc25cb
merge main
TalDerei Jun 13, 2024
d9b7373
fix broken lockfile, update deps, update db version
TalDerei Jun 13, 2024
f53027a
remove dangling minifront_url
TalDerei Jun 13, 2024
6532dbc
co-locate idb version with storage package
TalDerei Jun 13, 2024
f11da6a
remove idb version from extension .env
TalDerei Jun 13, 2024
89a24c1
support actions for alt fees
TalDerei Jun 14, 2024
ccfe1cf
linting and organization cleanup
TalDerei Jun 14, 2024
81e04bb
merge main post prax migration
TalDerei Jun 14, 2024
92e3dbd
update lockfile
TalDerei Jun 14, 2024
12afb80
Merge branch 'main' into ux-multi-fees
TalDerei Jun 15, 2024
1c8a6a4
remove extension dir trace
TalDerei Jun 15, 2024
2b60977
fix lockfile?
TalDerei Jun 15, 2024
b20ea66
address valentine comments
TalDerei Jun 17, 2024
0717d90
merge main
TalDerei Jun 17, 2024
04a0d65
fix test suite
TalDerei Jun 17, 2024
8721bc2
try fixing rust tests
Valentine1898 Jun 17, 2024
e570f2e
rust lint
Valentine1898 Jun 17, 2024
71ce2d3
rust lint
Valentine1898 Jun 17, 2024
3e9101f
add TODOs for #1310
Valentine1898 Jun 17, 2024
5de82b8
fix lint
Valentine1898 Jun 17, 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
19 changes: 19 additions & 0 deletions apps/minifront/src/components/send/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumb
import { getBalances } from '../../fetchers/balances';
import { getMetadata } from '@penumbra-zone/getters/value-view';
import { isUnknown } from '../dashboard/assets-table/helpers';
import { uint8ArrayToBase64 } from '@penumbra-zone/types/base64';

export const penumbraAddrValidation = (): Validation => {
return {
Expand All @@ -31,3 +32,21 @@ export const getTransferableBalancesResponses = async (): Promise<BalancesRespon
balance => isUnknown(balance) || isTransferable(getMetadata(balance.balanceView)),
);
};

export const hasStakingToken = (
assetBalances: BalancesResponse[],
feeAssetMetadata: Metadata,
TalDerei marked this conversation as resolved.
Show resolved Hide resolved
): boolean => {
let stakingToken = false;
for (const asset of assetBalances) {
if (asset.balanceView?.valueView.case === 'knownAssetId') {
const assetId = asset.balanceView.valueView.value.metadata?.penumbraAssetId?.inner;
const feeAssetId = feeAssetMetadata.penumbraAssetId?.inner;
if (assetId && feeAssetId && uint8ArrayToBase64(assetId) === uint8ArrayToBase64(feeAssetId)) {
stakingToken = true;
}
}
}

return stakingToken;
};
TalDerei marked this conversation as resolved.
Show resolved Hide resolved
24 changes: 22 additions & 2 deletions apps/minifront/src/components/send/send-form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ 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 { getTransferableBalancesResponses, penumbraAddrValidation } from '../helpers';
import { useMemo, useState } from 'react';
import {
getTransferableBalancesResponses,
hasStakingToken,
penumbraAddrValidation,
} from '../helpers';
import { abortLoader } from '../../../abort-loader';
import InputToken from '../../shared/input-token';
import { useRefreshFee } from './use-refresh-fee';
Expand Down Expand Up @@ -51,6 +55,11 @@ export const SendForm = () => {
sendTx,
txInProgress,
} = useStore(sendSelector);
// State to manage privacy warning display
const [showWarning, setShowWarning] = useState(false);
TalDerei marked this conversation as resolved.
Show resolved Hide resolved

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

useRefreshFee();

Expand Down Expand Up @@ -90,6 +99,8 @@ export const SendForm = () => {
onInputChange={amount => {
if (Number(amount) < 0) return;
setAmount(amount);
// Conditionally prompt a privacy warning about non-native fee tokens
setShowWarning(Number(amount) > 0 && !stakingToken);
}}
validations={[
{
Expand All @@ -100,6 +111,15 @@ export const SendForm = () => {
]}
balances={assetBalances}
/>
{showWarning && (
<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>
)}
TalDerei marked this conversation as resolved.
Show resolved Hide resolved

<GasFee
fee={fee}
Expand Down
1 change: 1 addition & 0 deletions packages/services/src/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface IndexedDbMock {
getPricesForAsset?: Mock;
getAuction?: Mock;
getAuctionOutstandingReserves?: Mock;
hasStakingAssetBalance?: Mock;
}

export interface AuctionMock {
Expand Down
49 changes: 49 additions & 0 deletions packages/services/src/view-service/fees.ts
TalDerei marked this conversation as resolved.
Show resolved Hide resolved
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';

export const extractAltFee = (request: TransactionPlannerRequest): AssetId | undefined => {
TalDerei marked this conversation as resolved.
Show resolved Hide resolved
// 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) {
console.warn('No non-empty field found in the request.');
return undefined;
TalDerei marked this conversation as resolved.
Show resolved Hide resolved
}

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.');
return undefined;
TalDerei marked this conversation as resolved.
Show resolved Hide resolved
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ describe('TransactionPlanner request handler', () => {
getAppParams: vi.fn(),
getGasPrices: vi.fn(),
constants: vi.fn(),
hasStakingAssetBalance: vi.fn(),
};

mockServices = {
Expand Down Expand Up @@ -77,47 +78,9 @@ describe('TransactionPlanner request handler', () => {
compactBlockSpacePrice: 120n,
}),
);
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');
});
TalDerei marked this conversation as resolved.
Show resolved Hide resolved
});
22 changes: 21 additions & 1 deletion packages/services/src/view-service/transaction-planner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,30 @@ import { Code, ConnectError } from '@connectrpc/connect';
import { assertSwapAssetsAreNotTheSame } from './assert-swap-assets-are-not-the-same';
import { TransactionPlannerRequest } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb';
import { fvkCtx } from '../../ctx/full-viewing-key';
import { extractAltFee } from '../fees';
import { AssetId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb';
import { TransactionPlan } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/transaction/v1/transaction_pb';

export const transactionPlanner: Impl['transactionPlanner'] = async (req, ctx) => {
const services = await ctx.values.get(servicesCtx)();
const { indexedDb } = await services.getWalletServices();

// Retrieve the staking token from asset registry
const stakingTokenId = indexedDb.fetchStakingTokenId();

// Query IndexedDB directly to check for the existence of staking token
const nativeToken = await indexedDb.hasStakingAssetBalance(stakingTokenId);

// Initialize the gas fee token using an native staking token's asset ID
let gasFeeToken = new AssetId({
inner: stakingTokenId.inner,
});

// If there is no native token balance, extract and use an alternate gas fee token
if (!nativeToken) {
gasFeeToken = extractAltFee(req)!;
}

const fvk = ctx.values.get(fvkCtx);

assertValidRequest(req);
Expand All @@ -24,7 +43,8 @@ export const transactionPlanner: Impl['transactionPlanner'] = async (req, ctx) =

const idbConstants = indexedDb.constants();

const plan = await planTransaction(idbConstants, req, await fvk());
const plan: TransactionPlan = await planTransaction(idbConstants, req, await fvk(), gasFeeToken);

return { plan };
};

Expand Down
5 changes: 5 additions & 0 deletions packages/storage/src/indexed-db/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* The version number for the IndexedDB schema. This version number is used to manage
* database upgrades and ensure that the correct schema version is applied.
*/
export const IDB_VERSION = 43;
72 changes: 52 additions & 20 deletions packages/storage/src/indexed-db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ import {
} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/auction/v1/auction_pb';
import { ChainRegistryClient } from '@penumbra-labs/registry';
import { PartialMessage } from '@bufbuild/protobuf';
import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1/num_pb';
import { addLoHi } from '@penumbra-zone/types/lo-hi';

interface IndexedDbProps {
idbVersion: number; // Incremented during schema changes
Expand Down Expand Up @@ -101,9 +103,11 @@ export class IndexedDb implements IndexedDbInterface {

db.createObjectStore('FULL_SYNC_HEIGHT');
db.createObjectStore('ASSETS', { keyPath: 'penumbraAssetId.inner' });
db.createObjectStore('SPENDABLE_NOTES', {
const spendableNoteStore = db.createObjectStore('SPENDABLE_NOTES', {
keyPath: 'noteCommitment.inner',
}).createIndex('nullifier', 'nullifier.inner');
});
spendableNoteStore.createIndex('nullifier', 'nullifier.inner');
spendableNoteStore.createIndex('assetId', 'note.value.assetId.inner');
db.createObjectStore('TRANSACTIONS', { keyPath: 'id.inner' });
db.createObjectStore('TREE_LAST_POSITION');
db.createObjectStore('TREE_LAST_FORGOTTEN');
Expand Down Expand Up @@ -147,9 +151,6 @@ export class IndexedDb implements IndexedDbInterface {
const existing0thEpoch = await instance.getEpochByHeight(0n);
if (!existing0thEpoch) await instance.addEpoch(0n); // Create first epoch

// set non-zero gas prices in indexDB since the testnet has not yet enabled gas fees.
await instance.initGasPrices();

return instance;
}

Expand Down Expand Up @@ -292,21 +293,13 @@ export class IndexedDb implements IndexedDbInterface {
);
}

async initGasPrices() {
const savedGasPrices = await this.getGasPrices();
// These are arbitrarily set, but can take on any value.
// The gas prices set here will determine the fees to use Penumbra.
//
// Note: this is a temporary measure to enable gas prices in the web, but once
// https://github.com/penumbra-zone/penumbra/issues/4306 is merged, we can remove this.
if (!savedGasPrices) {
await this.saveGasPrices({
verificationPrice: 1n,
executionPrice: 1n,
blockSpacePrice: 1n,
compactBlockSpacePrice: 1n,
});
}
TalDerei marked this conversation as resolved.
Show resolved Hide resolved
async *iterateSpendableStakingNotes() {
yield* new ReadableStream(
new IdbCursorSource(
this.db.transaction('SPENDABLE_NOTES').store.openCursor(),
SpendableNoteRecord,
),
);
TalDerei marked this conversation as resolved.
Show resolved Hide resolved
}

async *iterateTransactions() {
Expand Down Expand Up @@ -824,4 +817,43 @@ export class IndexedDb implements IndexedDbInterface {
output: Value.fromJson(result.output),
};
}

fetchStakingTokenId(): AssetId {
const registryClient = new ChainRegistryClient();
const registry = registryClient.get(this.chainId);
const stakingToken = registry.stakingAssetId;

return stakingToken;
}
TalDerei marked this conversation as resolved.
Show resolved Hide resolved

async hasStakingAssetBalance(assetId: AssetId): Promise<boolean> {
const spendableUMNotes = await this.db.getAllFromIndex(
'SPENDABLE_NOTES',
'assetId',
uint8ArrayToBase64(assetId.inner),
);

// Iterate over the spendable UM notes, and accrue balance for unspent notes
const stakingTokenBalance = new Amount();
for (const note of spendableUMNotes) {
const umNote = SpendableNoteRecord.fromJson(note);
if (umNote.heightSpent === 0n) {
const noteAmount = addLoHi(
{ lo: stakingTokenBalance.lo, hi: stakingTokenBalance.hi },
{
lo: BigInt(umNote.note?.value?.amount?.lo ?? 0n),
hi: BigInt(umNote.note?.value?.amount?.hi ?? 0n),
},
);
stakingTokenBalance.lo = noteAmount.lo;
stakingTokenBalance.hi = noteAmount.hi;
}
}

if (stakingTokenBalance.lo > 0) {
return true;
}

return false;
}
TalDerei marked this conversation as resolved.
Show resolved Hide resolved
}
7 changes: 7 additions & 0 deletions packages/types/src/indexed-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ export interface IndexedDbInterface {
getAuctionOutstandingReserves(
auctionId: AuctionId,
): Promise<{ input: Value; output: Value } | undefined>;

hasStakingAssetBalance(assetId: AssetId): Promise<boolean>;

fetchStakingTokenId(): AssetId;
}

export interface PenumbraDb extends DBSchema {
Expand Down Expand Up @@ -192,6 +196,9 @@ export interface PenumbraDb extends DBSchema {
value: Jsonified<SpendableNoteRecord>;
indexes: {
nullifier: Jsonified<Required<SpendableNoteRecord>['nullifier']['inner']>; // base64
assetId: Jsonified<
Required<Required<Required<SpendableNoteRecord>['note']>['value']>['assetId']['inner']
>; // base64
};
};

Expand Down
20 changes: 20 additions & 0 deletions packages/ui/components/ui/tx/view/registry.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ChainRegistryClient } from '@penumbra-labs/registry';
import {
AssetId,
Metadata,
} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb';

export function getStakingTokenMetaData(
chainId: string,
assetId: AssetId | undefined,
): Metadata | undefined {
const registryClient = new ChainRegistryClient();
const registry = registryClient.get(chainId);

if (assetId === undefined) {
assetId = registry.stakingAssetId;
}
const StakingTokenMetadata = registry.getMetadata(assetId);

return StakingTokenMetadata;
}
TalDerei marked this conversation as resolved.
Show resolved Hide resolved
Loading
Loading