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

Candlesticks #1220

Merged
merged 10 commits into from
Jun 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/great-carrots-mate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@penumbra-zone/ui': minor
---

add candlestick component
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import { sctImpl } from '@penumbra-zone/services/sct-service';
import { stakeImpl } from '@penumbra-zone/services/stake-service';
import { viewImpl } from '@penumbra-zone/services/view-service';
import { createProxyImpl, noContextHandler } from '@penumbra-zone/transport-dom/proxy';
import { rethrowImplErrors } from './utils/rethrow-impl-errors';
import { rethrowImplErrors } from './rethrow-impl-errors';
import { makeTendermintProxyZeroNanos } from './tendermint-proxy';

type RpcImplTuple<T extends ServiceType> = [T, Partial<ServiceImpl<T>>];

Expand All @@ -40,7 +41,6 @@ export const getRpcImpls = (baseUrl: string) => {
IbcConnectionService,
ShieldedPoolService,
SimulationService,
TendermintProxyService,
].map(
serviceType =>
[
Expand All @@ -59,8 +59,24 @@ export const getRpcImpls = (baseUrl: string) => {
[SctService, rethrowImplErrors(SctService, sctImpl)],
[StakeService, rethrowImplErrors(StakeService, stakeImpl)],
[ViewService, rethrowImplErrors(ViewService, viewImpl)],
// rpc remote proxies
...penumbraProxies,
// customized proxy
[
TendermintProxyService,
rethrowImplErrors(
TendermintProxyService,
createProxyImpl(
TendermintProxyService,
createPromiseClient(TendermintProxyService, webTransport),
noContextHandler,
makeTendermintProxyZeroNanos,
),
),
],
// simple proxies
...penumbraProxies.map(
([serviceType, impl]) =>
[serviceType, rethrowImplErrors(serviceType, impl)] as [typeof serviceType, typeof impl],
),
] as const;

return rpcImpls;
Expand Down
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

these debug logs are conditional and compile out in prod

Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ const wrapUnaryImpl =
const result = methodImplementation(req, ctx);
if (result instanceof Promise)
return result.catch((e: unknown) => {
if (process.env['NODE_ENV'] === 'development') console.debug(ctx.method.name, req, e);
throw ConnectError.from(e);
});
return result;
} catch (e) {
if (process.env['NODE_ENV'] === 'development') console.debug(ctx.method.name, req, e);
throw ConnectError.from(e);
}
};
Expand All @@ -29,6 +31,7 @@ const wrapServerStreamingImpl = (
yield result;
}
} catch (e) {
if (process.env['NODE_ENV'] === 'development') console.debug(ctx.method.name, req, e);
throw ConnectError.from(e);
}
};
Expand Down
30 changes: 30 additions & 0 deletions apps/extension/src/rpc/tendermint-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { PromiseClient, ServiceImpl } from '@connectrpc/connect';
import { TendermintProxyService } from '@penumbra-zone/protobuf';

/**
* This function is used to create a proxy that clobbers the nanos on the block
* header timestamp and removes the list of commit signatures from the block.
* Minifront doesn't use this request for anything related to consensus, so it's
* safe for Minifront, but it may break other consumers.
*
* This is necessary because the CometBFT nanos field does not comply with the
* google.protobuf.Timestamp spec, and may include negative nanos. This causes
* failures when re-serializing the block header: bufbuild deserializes the
* negative nanos, but refuses to re-serialize.
*
* Ideally,
* - cometbft serialization should be compliant with google.protobuf.Timestamp
* - bufbuild should provide JsonSerializationOption to disable the error
*
* We should explore PRing bufbuild or monkey-patching the serialization.
*/
export const makeTendermintProxyZeroNanos = (
c: PromiseClient<typeof TendermintProxyService>,
): Pick<ServiceImpl<typeof TendermintProxyService>, 'getBlockByHeight'> => ({
getBlockByHeight: async req => {
const r = await c.getBlockByHeight(req);
if (r.block?.header?.time?.nanos) r.block.header.time.nanos = 0;
if (r.block?.lastCommit?.signatures.length) r.block.lastCommit.signatures = [];
return r;
},
});
2 changes: 1 addition & 1 deletion apps/extension/src/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { Services } from '@penumbra-zone/services-context';
import { backOff } from 'exponential-backoff';

// all rpc implementations, local and proxy
import { getRpcImpls } from './get-rpc-impls';
import { getRpcImpls } from './rpc';

// adapter
import { ConnectRouter, createContextValues, PromiseClient } from '@connectrpc/connect';
Expand Down
2 changes: 2 additions & 0 deletions apps/minifront/src/clients.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createPraxClient } from '@penumbra-zone/client/prax';
import {
CustodyService,
DexService,
IbcChannelService,
IbcClientService,
IbcConnectionService,
Expand All @@ -12,6 +13,7 @@ import {
} from '@penumbra-zone/protobuf';

export const custodyClient = createPraxClient(CustodyService);
export const dexClient = createPraxClient(DexService);
export const ibcChannelClient = createPraxClient(IbcChannelService);
export const ibcClient = createPraxClient(IbcClientService);
export const ibcConnectionClient = createPraxClient(IbcConnectionService);
Expand Down
103 changes: 68 additions & 35 deletions apps/minifront/src/components/swap/swap-form/token-swap-input.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb';
import { Box } from '@penumbra-zone/ui/components/ui/box';
import BalanceSelector from '../../shared/balance-selector';
import { ArrowRight } from 'lucide-react';
import { AssetSelector } from '../../shared/asset-selector';
import { getAmount } from '@penumbra-zone/getters/balances-response';
import { joinLoHiAmount } from '@penumbra-zone/types/amount';
import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view';
import { BalanceValueView } from '@penumbra-zone/ui/components/ui/balance-value-view';
import { Box } from '@penumbra-zone/ui/components/ui/box';
import { CandlestickPlot } from '@penumbra-zone/ui/components/ui/candlestick-plot';
import { Input } from '@penumbra-zone/ui/components/ui/input';
import { joinLoHiAmount } from '@penumbra-zone/types/amount';
import { getAmount } from '@penumbra-zone/getters/balances-response';
import { amountMoreThanBalance } from '../../../state/send';
import { ArrowRight } from 'lucide-react';
import { useEffect } from 'react';
import { getBlockDate } from '../../../fetchers/block-date';
import { AllSlices } from '../../../state';
import { amountMoreThanBalance } from '../../../state/send';
import { useStoreShallow } from '../../../utils/use-store-shallow';
import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view';
import { AssetSelector } from '../../shared/asset-selector';
import BalanceSelector from '../../shared/balance-selector';

const isValidAmount = (amount: string, assetIn?: BalancesResponse) =>
Number(amount) >= 0 && (!assetIn || !amountMoreThanBalance(assetIn, amount));
Expand All @@ -24,6 +27,8 @@ const tokenSwapInputSelector = (state: AllSlices) => ({
amount: state.swap.amount,
setAmount: state.swap.setAmount,
balancesResponses: state.swap.balancesResponses,
priceHistory: state.swap.priceHistory,
latestKnownBlockHeight: state.status.latestKnownBlockHeight,
});

/**
Expand All @@ -42,7 +47,21 @@ export const TokenSwapInput = () => {
assetOut,
setAssetOut,
balancesResponses,
priceHistory,
latestKnownBlockHeight = 0n,
} = useStoreShallow(tokenSwapInputSelector);

useEffect(() => {
if (!assetIn || !assetOut) return;
else return priceHistory.load();
}, [assetIn, assetOut]);

useEffect(() => {
if (!priceHistory.candles.length) return;
else if (latestKnownBlockHeight % 10n) return;
else return priceHistory.load();
}, [priceHistory, latestKnownBlockHeight]);

const maxAmount = getAmount.optional()(assetIn);
const maxAmountAsString = maxAmount ? joinLoHiAmount(maxAmount).toString() : undefined;

Expand All @@ -55,38 +74,52 @@ export const TokenSwapInput = () => {

return (
<Box label='Trade' layout>
<div className='flex flex-col items-start gap-4 sm:flex-row'>
<div className='flex grow flex-col items-start gap-2'>
<Input
value={amount}
type='number'
inputMode='decimal'
variant='transparent'
placeholder='Enter an amount...'
max={maxAmountAsString}
step='any'
className={'font-bold leading-10 md:h-8 md:text-xl xl:h-10 xl:text-3xl'}
onChange={e => {
if (!isValidAmount(e.target.value, assetIn)) return;
setAmount(e.target.value);
}}
/>
{assetIn?.balanceView && (
<BalanceValueView valueView={assetIn.balanceView} onClick={setInputToBalanceMax} />
)}
</div>

<div className='flex items-center justify-between gap-4'>
<div className='flex flex-col gap-1'>
<BalanceSelector value={assetIn} onChange={setAssetIn} balances={balancesResponses} />
<div className='gap-4'>
<div className='flex flex-col items-start gap-4 sm:flex-row'>
<div className='flex grow flex-row items-start gap-2'>
<Input
value={amount}
type='number'
inputMode='decimal'
variant='transparent'
placeholder='Enter an amount...'
max={maxAmountAsString}
step='any'
className={'font-bold leading-10 md:h-8 md:text-xl xl:h-10 xl:text-3xl'}
onChange={e => {
if (!isValidAmount(e.target.value, assetIn)) return;
setAmount(e.target.value);
}}
/>
{assetIn?.balanceView && (
<div className='h-6'>
<BalanceValueView valueView={assetIn.balanceView} onClick={setInputToBalanceMax} />
</div>
)}
</div>

<ArrowRight size={16} className='text-muted-foreground' />
<div className='flex items-center justify-between gap-4'>
<div className='flex flex-col gap-1'>
<BalanceSelector value={assetIn} onChange={setAssetIn} balances={balancesResponses} />
</div>

<ArrowRight size={16} className='text-muted-foreground' />

<div className='flex flex-col items-end gap-1'>
<AssetSelector assets={swappableAssets} value={assetOut} onChange={setAssetOut} />
<div className='flex flex-col items-end gap-1'>
<AssetSelector assets={swappableAssets} value={assetOut} onChange={setAssetOut} />
</div>
</div>
</div>
{priceHistory.startMetadata && priceHistory.endMetadata && priceHistory.candles.length && (
<CandlestickPlot
className='h-[480px] w-full bg-charcoal'
candles={priceHistory.candles}
startMetadata={priceHistory.startMetadata}
endMetadata={priceHistory.endMetadata}
latestKnownBlockHeight={Number(latestKnownBlockHeight)}
getBlockDate={getBlockDate}
/>
)}
</div>
</Box>
);
Expand Down
9 changes: 9 additions & 0 deletions apps/minifront/src/fetchers/block-date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { tendermintClient } from '../clients';

export const getBlockDate = async (
height: bigint,
signal?: AbortSignal,
): Promise<Date | undefined> => {
const { block } = await tendermintClient.getBlockByHeight({ height }, { signal });
return block?.header?.time?.toDate();
};
42 changes: 35 additions & 7 deletions apps/minifront/src/state/swap/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { Value } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb';
import { SwapSlice } from '.';
import {
CandlestickData,
SimulateTradeRequest,
SimulateTradeResponse,
} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/dex/v1/dex_pb';
import { getAssetId } from '@penumbra-zone/getters/metadata';
import {
getAssetIdFromValueView,
getDisplayDenomExponentFromValueView,
} from '@penumbra-zone/getters/value-view';
import { toBaseUnit } from '@penumbra-zone/types/lo-hi';
import { BigNumber } from 'bignumber.js';
import {
SimulateTradeRequest,
SimulateTradeResponse,
} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/dex/v1/dex_pb';
import { getAssetId } from '@penumbra-zone/getters/metadata';
import { simulationClient } from '../../clients';
import { SwapSlice } from '.';
import { dexClient, simulationClient } from '../../clients';
import { PriceHistorySlice } from './price-history';

export const sendSimulateTradeRequest = ({
assetIn,
Expand All @@ -35,3 +37,29 @@ export const sendSimulateTradeRequest = ({

return simulationClient.simulateTrade(req);
};

export const sendCandlestickDataRequest = async (
{ startMetadata, endMetadata }: Pick<PriceHistorySlice, 'startMetadata' | 'endMetadata'>,
limit: bigint,
signal?: AbortSignal,
): Promise<CandlestickData[] | undefined> => {
const start = startMetadata?.penumbraAssetId;
const end = endMetadata?.penumbraAssetId;

if (!start || !end) throw new Error('Asset pair incomplete');
if (start.equals(end)) throw new Error('Asset pair equivalent');

try {
const { data } = await dexClient.candlestickData(
{
pair: { start, end },
limit,
},
{ signal },
);
return data;
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return;
else throw err;
}
};
18 changes: 10 additions & 8 deletions apps/minifront/src/state/swap/index.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import { SliceCreator } from '..';
import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb';
import {
Metadata,
ValueView,
} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb';
import { SwapExecution_Trace } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/dex/v1/dex_pb';
import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb';
import { SliceCreator } from '..';
import { DurationOption } from './constants';
import { DutchAuctionSlice, createDutchAuctionSlice } from './dutch-auction';
import { InstantSwapSlice, createInstantSwapSlice } from './instant-swap';
import { DurationOption } from './constants';
import { PriceHistorySlice, createPriceHistorySlice } from './price-history';

export interface SimulateSwapResult {
metadataByAssetId: Record<string, Metadata>;
output: ValueView;
unfilled: ValueView;
priceImpact: number | undefined;
traces?: SwapExecution_Trace[];
metadataByAssetId: Record<string, Metadata>;
unfilled: ValueView;
}

interface Actions {
Expand All @@ -40,6 +41,7 @@ interface State {
interface Subslices {
dutchAuction: DutchAuctionSlice;
instantSwap: InstantSwapSlice;
priceHistory: PriceHistorySlice;
}

const INITIAL_STATE: State = {
Expand All @@ -54,6 +56,9 @@ export type SwapSlice = Actions & State & Subslices;

export const createSwapSlice = (): SliceCreator<SwapSlice> => (set, get, store) => ({
...INITIAL_STATE,
dutchAuction: createDutchAuctionSlice()(set, get, store),
instantSwap: createInstantSwapSlice()(set, get, store),
priceHistory: createPriceHistorySlice()(set, get, store),
setBalancesResponses: balancesResponses => {
set(state => {
state.swap.balancesResponses = balancesResponses;
Expand All @@ -64,7 +69,6 @@ export const createSwapSlice = (): SliceCreator<SwapSlice> => (set, get, store)
state.swap.swappableAssets = swappableAssets;
});
},
assetIn: undefined,
setAssetIn: asset => {
get().swap.resetSubslices();
set(({ swap }) => {
Expand All @@ -83,8 +87,6 @@ export const createSwapSlice = (): SliceCreator<SwapSlice> => (set, get, store)
swap.amount = amount;
});
},
dutchAuction: createDutchAuctionSlice()(set, get, store),
instantSwap: createInstantSwapSlice()(set, get, store),
setDuration: duration => {
get().swap.resetSubslices();
set(state => {
Expand Down
Loading
Loading