Skip to content

Commit

Permalink
Display recent executions
Browse files Browse the repository at this point in the history
  • Loading branch information
grod220 committed Dec 17, 2024
1 parent 82ff5f4 commit 48c5556
Show file tree
Hide file tree
Showing 6 changed files with 251 additions and 12 deletions.
1 change: 1 addition & 0 deletions app/api/recent-executions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { GET } from '@/shared/api/server/recent-executions';
63 changes: 63 additions & 0 deletions src/pages/trade/api/recent-executions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { useQuery } from '@tanstack/react-query';
import { useRefetchOnNewBlock } from '@/shared/api/compact-block.ts';
import { usePathSymbols } from '@/pages/trade/model/use-path.ts';
import { apiFetch } from '@/shared/utils/api-fetch.ts';
import { RecentExecution } from '@/shared/api/server/recent-executions.ts';
import { registryQueryFn } from '@/shared/api/registry.ts';
import { Registry } from '@penumbra-labs/registry';
import { formatAmount } from '@penumbra-zone/types/amount';
import { getDisplayDenomExponent } from '@penumbra-zone/getters/metadata';
import { calculateDisplayPrice } from '@/shared/utils/price-conversion.ts';

export interface RecentExecutionVV {
kind: 'buy' | 'sell';
amount: string;
price: number;
timestamp: string;
}

const addVV = (res: RecentExecution[], registry: Registry): RecentExecutionVV[] => {
return res.map(r => {
if (!r.amount.assetId || !r.amount.amount) {
throw new Error('No asseId or Amount passed for recent execution');
}
const baseMetadata = registry.getMetadata(r.amount.assetId);
const baseDisplayDenomExponent = getDisplayDenomExponent.optional(baseMetadata) ?? 0;

const quoteMetadata = registry.getMetadata(r.price.assetId);
return {
kind: r.kind,
amount: formatAmount({
amount: r.amount.amount,
exponent: baseDisplayDenomExponent,
}),
price: calculateDisplayPrice(r.price.amount, baseMetadata, quoteMetadata),
timestamp: r.timestamp,
};
});
};

const LIMIT = 10;

export const useRecentExecutions = () => {
const { baseSymbol, quoteSymbol } = usePathSymbols();

const query = useQuery({
queryKey: ['recent-executions', baseSymbol, quoteSymbol],
queryFn: async () => {
const results = await apiFetch<RecentExecution[]>('/api/recent-executions', {
baseAsset: baseSymbol,
quoteAsset: quoteSymbol,
limit: String(LIMIT),
});

const registry = await registryQueryFn();

return addVV(results, registry);
},
});

useRefetchOnNewBlock('recent-executions', query);

return query;
};
67 changes: 56 additions & 11 deletions src/pages/trade/ui/market-trades.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Text } from '@penumbra-zone/ui/Text';
import { ReactNode } from 'react';
import { Skeleton } from '@/shared/ui/skeleton';
import { RecentExecutionVV, useRecentExecutions } from '@/pages/trade/api/recent-executions.ts';
import cn from 'clsx';
import { Density } from '@penumbra-zone/ui/Density';

export const Cell = ({ children }: { children: ReactNode }) => {
return <div className='flex items-center py-1.5 px-3 min-h-12'>{children}</div>;
Expand Down Expand Up @@ -37,19 +40,61 @@ const LoadingRow = () => {
);
};

const ErrorState = ({ error }: { error: Error }) => {
return <div className='text-red-500'>{String(error)}</div>;
};

const formatLocalTime = (isoString: string): string => {
const date = new Date(isoString);
return date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
};

const LoadedState = ({ data }: { data: RecentExecutionVV[] }) => {
return data.map((e, i) => {
return (
<div
key={i}
className='grid grid-cols-subgrid col-span-4 text-text-secondary border-b border-other-tonalStroke'
>
<Cell>
<span className={cn(e.kind === 'buy' ? 'text-success-light' : 'text-destructive-light')}>
{e.price}
</span>
</Cell>
<Cell>{e.amount}</Cell>
<Cell>
<Text small color='text.primary'>
{formatLocalTime(e.timestamp)}
</Text>
</Cell>
<Cell>-</Cell>
</div>
);
});
};

export const MarketTrades = () => {
const { data, isLoading, error } = useRecentExecutions();

return (
<div className='grid grid-cols-4 pt-4 px-4 pb-0 h-auto overflow-auto'>
<div className='grid grid-cols-subgrid col-span-4 text-text-secondary border-b border-other-tonalStroke'>
<HeaderCell>Price</HeaderCell>
<HeaderCell>Amount</HeaderCell>
<HeaderCell>Time</HeaderCell>
<HeaderCell>Route</HeaderCell>
</div>
<Density slim>
<div className='grid grid-cols-4 pt-4 px-4 pb-0 h-auto overflow-auto'>
<div className='grid grid-cols-subgrid col-span-4 text-text-secondary border-b border-other-tonalStroke'>
<HeaderCell>Price</HeaderCell>
<HeaderCell>Amount</HeaderCell>
<HeaderCell>Time</HeaderCell>
<HeaderCell>Route</HeaderCell>
</div>

{new Array(15).fill(0).map((_, i) => (
<LoadingRow key={i} />
))}
</div>
{isLoading && new Array(15).fill(0).map((_, i) => <LoadingRow key={i} />)}
{error && <ErrorState error={error} />}
{data && <LoadedState data={data} />}
</div>
</Density>
);
};
4 changes: 3 additions & 1 deletion src/pages/trade/ui/trades-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ export const TradesTabs = ({ withChart = false }: { withChart?: boolean }) => {
</div>
)}
{tab === TradesTabsType.MarketTrades && <MarketTrades />}
{tab === TradesTabsType.MyTrades && <MarketTrades />}
{tab === TradesTabsType.MyTrades && (
<div className='text-text-secondary p-4'>Coming soon...</div>
)}
</>
)}
</div>
Expand Down
109 changes: 109 additions & 0 deletions src/shared/api/server/recent-executions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server';
import { pindexer } from '@/shared/database';
import { ChainRegistryClient } from '@penumbra-labs/registry';
import { serialize, Serialized } from '@/shared/utils/serializer';
import { AssetId, Value } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb';
import { pnum } from '@penumbra-zone/types/pnum';

const transformDbVal = ({
context_asset_end,
context_asset_start,
delta_1,
delta_2,
lambda_1,
lambda_2,
time,
}: {
context_asset_end: Buffer;
context_asset_start: Buffer;
delta_1: string;
delta_2: string;
lambda_1: string;
lambda_2: string;
time: Date;
}): RecentExecution => {
const baseAssetId = new AssetId({
inner: Uint8Array.from(context_asset_start),
});
const quoteAssetId = new AssetId({ inner: Uint8Array.from(context_asset_end) });

// Determine trade direction
const isBaseAssetInput = BigInt(delta_1) !== 0n;
const kind = isBaseAssetInput ? 'sell' : 'buy';

// Amount of base & quote asset being traded in or out of
const baseAmount = isBaseAssetInput ? pnum(delta_1) : pnum(lambda_1);
const quoteAmount = isBaseAssetInput ? pnum(lambda_2) : pnum(delta_2);

const price = baseAmount.toBigNumber().div(quoteAmount.toBigNumber()).toNumber();
const timestamp = time.toISOString();

return {
kind,
amount: new Value({ amount: baseAmount.toAmount(), assetId: baseAssetId }),
price: { amount: price, assetId: quoteAssetId },
timestamp,
};
};

export type RecentExecutionsResponse = RecentExecution[] | { error: string };

interface FloatValue {
assetId: AssetId;
amount: number;
}

export interface RecentExecution {
kind: 'buy' | 'sell';
amount: Value;
price: FloatValue;
timestamp: string;
}

export async function GET(
req: NextRequest,
): Promise<NextResponse<Serialized<RecentExecutionsResponse>>> {
const chainId = process.env['PENUMBRA_CHAIN_ID'];
if (!chainId) {
return NextResponse.json({ error: 'PENUMBRA_CHAIN_ID is not set' }, { status: 500 });
}

const { searchParams } = new URL(req.url);
const baseAssetSymbol = searchParams.get('baseAsset');
const quoteAssetSymbol = searchParams.get('quoteAsset');
const limit = searchParams.get('limit');
if (!baseAssetSymbol || !quoteAssetSymbol || !limit) {
return NextResponse.json(
{ error: 'Missing required baseAsset, quoteAsset, or limit' },
{ status: 400 },
);
}

const registryClient = new ChainRegistryClient();
const registry = await registryClient.remote.get(chainId);

// TODO: Add getMetadataBySymbol() helper to registry npm package
const allAssets = registry.getAllAssets();
const baseAssetMetadata = allAssets.find(
a => a.symbol.toLowerCase() === baseAssetSymbol.toLowerCase(),
);
const quoteAssetMetadata = allAssets.find(
a => a.symbol.toLowerCase() === quoteAssetSymbol.toLowerCase(),
);
if (!baseAssetMetadata?.penumbraAssetId || !quoteAssetMetadata?.penumbraAssetId) {
return NextResponse.json(
{ error: `Base asset or quoteAsset assetId not found in registry` },
{ status: 400 },
);
}

const results = await pindexer.recentExecutions(
baseAssetMetadata.penumbraAssetId,
quoteAssetMetadata.penumbraAssetId,
Number(limit),
);

const response = results.map(transformDbVal);

return NextResponse.json(serialize(response));
}
19 changes: 19 additions & 0 deletions src/shared/database/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,25 @@ class Pindexer {
.execute();
}

async recentExecutions(base: AssetId, quote: AssetId, amount: number) {
return await this.db
.selectFrom('dex_ex_position_executions')
.select([
'context_asset_end',
'context_asset_start',
'delta_1',
'delta_2',
'lambda_1',
'lambda_2',
'time',
])
.where('context_asset_start', '=', Buffer.from(base.inner))
.where('context_asset_end', '=', Buffer.from(quote.inner))
.orderBy('time', 'desc')
.limit(amount)
.execute();
}

async getPositionVolumeAndFees(positionId: PositionId): Promise<VolumeAndFees[]> {
const results = await this.db
.selectFrom('dex_ex_position_executions')
Expand Down

0 comments on commit 48c5556

Please sign in to comment.